All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Breaking: the package is published as @deepl/cli and requires Node.js 24.15.0 or later. Several exit codes moved, and under --format json a failing command now writes its error envelope to stdout. Every breaking change is covered with before/after examples in docs/MIGRATION.md — read it before upgrading, starting with Exit codes that moved.
- cli:
deepl correct(aliasc) — spelling and grammar correction without rewording, via the Write API's/v2/write/correctendpoint. Supports the same input handling and workflow flags aswrite(--checkwith exit code 8,--fix/--backup,--diff,--interactive,--output/--in-place,--format json,--no-cache) but not--style/--tone, which the endpoint does not accept. Results are cached under a separatecorrect:namespace. - translate:
--glossaryis repeatable, applying up to 5 glossaries to one request via the API'sglossary_idsparameter. Entries are merged; when several glossaries define the same source term, which mapping wins is the API's choice and does not follow flag order. Names and UUIDs may be mixed, order is sent as given and is part of the cache key, a single--glossarystill goes out asglossary_id, and a 6th exits 6 (ValidationError) before any API call.watchandsynckeep their single-glossary configuration. - translate:
--glossarynow applies to document translation (PDF, DOCX, PPTX, XLSX, images, and text-based files routed to the document API), where it was previously accepted and then discarded with a warning.--fromis required, since the API rejects a document glossary without a source language, and--translation-memoryremains unsupported for documents. Glossary matching is context-dependent for documents exactly as it is for text. - languages:
deepl languages --featuresshows which features each language supports — formality, glossary, style rules, translation memory, tag handling and auto-detection — from thefeaturesmatrix onGET /v3/languages. Which features get a column is derived from the response: a feature supported by every listed language is reported once as a footer note instead of a column, a language the response omitted reads asno feature datarather than as supporting nothing, and anything short of generally available renders verbatim (glossary (beta)). Works with--format table(one column per feature) and--format json(the raw matrix, present only when--featuresis passed), supersedes the[F]shorthand, and needs an API key — without one it warns and falls back to the bundled registry, which carries no feature data. - write/correct:
--check,--diffand--alternativeshonour--format json, each with a payload of its own on stdout:{ ok: true, mode: 'write'|'correct', needsChanges, changes, file? },{ ok: true, original, improved, diff }(the unified patch, never colour-escaped whatever the terminal reports), and{ ok: true, original, alternatives: [...] }as an array rather than a numbered list to parse by line.ok: truediscriminates a result from theok: falseerror envelope,fileis the absolute path and present only for file input, and--checkdeliberately omitsoriginal/improved. Each payload replaces the human report rather than joining it; the plain improvement payload keeps its existing{ original, improved, changes, language }shape and its absentok, and exit codes are unchanged in both modes. - sync:
deepl sync pull --dry-runpreviews a pull without writing anything — no target file, no.deepl-sync.lock. It reports how many translations would be pulled and how many existing local translations would be replaced,--verbosenames each key and file whose TMS value differs from the local one, and--format jsoncarriesreplacedanddryRunalongsidepulled. Also accepted on the parent (deepl sync --dry-run pull). - sync:
--break-lockondeepl sync,deepl sync pullanddeepl sync resolvetakes the process lock even when.deepl-sync.lock.pidfilenames a holder that looks alive, printing the PID and start time it removed. It applies only to the run it is passed to, so a--watchsession breaks the lock for its first pass and then arbitrates normally. It is unsafe if that sync really is running — two concurrent runs write the same target files and overwrite each other's lockfile — and the warning says so. - cli: Global
--timeout <ms>and--max-retries <n>options override the HTTP transport defaults (30000 ms, 3 retries) for a single invocation. Neither was previously configurable from the CLI. - translate:
--format jsonoutput now includes the documentedcachedboolean, so scripts can distinguish cache hits from fresh API calls. - http:
NO_PROXY/no_proxyare honoured with the standard semantics —*for everything, a leading dot or*.for subdomains, and an optionalhost:portthat must agree — so a corporateHTTPS_PROXYis no longer applied to a request aimed at localhost. - auth:
deepl auth set-key --no-verifystores a key without validating it against the API. Validation ran before persisting, so on a network without proxy configuration both documented setup paths (auth set-keyandinit) failed and discarded the key; an unreachable API now also namesDEEPL_API_KEYas the zero-network alternative. - cli:
tandwcommand aliases fortranslateandwrite(#12), shown in--helpoutput and in bash/zsh/fish completions.wis deliberately assigned towriterather thanwatch. - ci: Pushing a
v*tag creates a GitHub Release, with notes extracted from that version's CHANGELOG section and generated notes as a fallback. The tag is checked againstpackage.jsonfirst, so a mislabelled tag cannot mint a Release whose title disagrees with the version it contains. The workflow does not publish to npm — publishing runs from a separate GitLab pipeline, so a tag pushed here records the Release and nothing more. - ci: A
Packaged artifactjob packs the tarball, installs it globally to rundeepl --version, then installs it into a throwaway consumer package and imports@deepl/cli, asserting the entry exports something. The suites run from the working tree andbinhas its own module graph, so a broken programmatic entry point could otherwise ship while every test passed. The test matrix also pins24.15.0alongside24, so theengines.nodefloor is exercised rather than only the latest 24.x. - ci:
npm run check-depsfails the build when a package imported bysrc/is missing fromdependencies, including one declared only underdevDependencies. It runs in CI and in the publish job, and matches package names as quoted strings so indirect loads such asrequireModule('php-parser')count as references.
- BREAKING — package: The package is now published as the scoped
@deepl/cli(previously the unpublished working namedeepl-cli), withpublishConfig.access: "public"set explicitly. Thebinname is unchanged — the command is stilldeepl— so scoping changes only the install string (npm install -g @deepl/cli). Repository, bugs and homepage metadata now point atgithub.com/DeepL/deepl-clidirectly. - BREAKING — cache/runtime: The translation cache now uses Node's built-in
node:sqliteinstead of thebetter-sqlite3native addon, and the CLI consequently requires Node.js >= 24.15.0 (engines.nodeis now>=24.15.0) — 24.15.0, not 24.0.0, is the release wherenode:sqlitestopped emittingExperimentalWarning: SQLite is an experimental feature. The floor is therefore checked as a major and minor at startup: every 24.x below 24.15.0 would otherwise write that warning to stderr on each cache-backed command, which breaks callers that merge stderr into stdout and parse--format json. Running under an unsupported Node fails fast with a clear one-line error (exit 6) instead of surfacing the warning or crashing later. Existing cache databases are read in place with no migration; the on-disk format is unchanged. Migration: upgrade to a current Node 24, e.g.nvm install 24. - BREAKING — cli: A failing command with
--format jsonnow writes its{ ok: false, error: { code, message, suggestion? }, exitCode }envelope to stdout instead of prose on stderr, for every command that has a JSON mode —translate,write,correct,voice,usage,languages,detect,glossary,tm,cache,config,hooks,admin,style-rules— and for everysyncsubcommand at once, from one shared writer.> out.jsonnow captures both the success payload and the failure envelope, and a human reading--format jsonoutput sees the envelope'smessage/suggestionfields where a prose sentence used to be.config get/config listdefault tojson, so their failures carry the envelope with no flag passed. Warnings stay on stderr, exit codes remain the failure signal, and text/table modes are byte-identical. A malformed invocation that commander rejects before the command runs still prints commander's message on stderr at exit 6. - BREAKING — cli: Language codes are displayed in lowercase everywhere, replacing three mixed casings.
glossary shownow reportsSource language: enanden → es: 5 entries,tm listrendersbrand-terms (en → de, fr),translate --format tablelabels rowsde, andwrite --format jsonreports"language": "en-us"— so scripts scraping these values see a casing change. Input is case-insensitive everywhere, so no command line has to change;voiceincluded, which previously demanded the exact mixed-case spelling of a regional code (--to zh-HANS) and rejected the lowercase form the rest of the CLI prints.write/correctalso send the lowercase code astarget_lang, which the Write API canonicalizes server-side. Wire parameters that are not display output are untouched:translateand the glossary create endpoint still send uppercasesource_lang/target_lang. - BREAKING — sync:
deepl syncnow exits 12 where it exited 0 in three cases: a locale whose target file it cannot read or parse (nothing is written for it rather than rebuilding it from the source), a key it could not write into the target file, and a translation that fails validation (the key is withheld and counted failed rather than written corrupt).sync --frozenexits 10 andsync statuscounts such keys against the locale;validation.fail_on_error: truestill promotes a validation error to exit 6, andSyncResult.successisfalsefor these runs.deepl sync pullreports an unreadable target as a newunusable_targetskip reason at its existing exit code. - BREAKING — sync:
deepl sync validateexits 8 where it exited 1 on a project whose target file cannot be read — reported in--format jsonunder a newunusable_targetcheck kind, withkeyandfileboth the target path and emptysource/translation, while every other locale is still validated — and exits 8 where it exited 0 on a PO or XLIFF project whose translations carry an error the check could not previously see, because it now reads the realmsgstr/<target>instead of comparing the source against itself. An untranslated entry (emptymsgstr, no<target>) is not a source/translation pair and is still not validated at all. - BREAKING — sync:
deepl sync push/deepl sync pullexit 5 (the documented retriable code) where they exited 1 when the TMS could not be reached. Request counts are unchanged. - BREAKING — sync:
deepl sync --forcewithout--yesnow exits 6 wherever it cannot prompt — piped or closed stdin, a git hook, a cron job, amaketarget, a container entrypoint,--no-input— not just underCI=true. See Security for why it was doing the opposite. - BREAKING — watch: A
deepl watchsession now exits 12 rather than 0 when it recorded any translation error or any--auto-commitfailure, and the auto-commit failure count is printed beside the translation total; a session with no failures still exits 0.deepl watch --auto-commitalso exits 6 at startup, naming the directory, when the output directory is in no git repository, instead of translating every file and skipping the commit each time. Automation that relied on the translations still being written in that case has to drop--auto-commit. - BREAKING — watch:
deepl watchwrites a nested source file to a nested output path: watchingdocs/with--output out,docs/guide/intro.mdnow lands atout/guide/intro.es.mdwhere it used to land atout/intro.es.md, matching whatdeepl translate <dir> --output <dir>has always produced. Anything reading a session's output by flat basename — a publish step, a.gitignoreentry, an--auto-commitdiff — has to follow the directory it now sits in. A file at the top of the watched directory, and a watched path that is a single file, are unchanged. - BREAKING — translate: A translation that lost one of your placeholders is a failure rather than output:
deepl translateexits 5 and writes nothing where it previously printed or wrote the CLI's internal__ Var_0 __scaffolding at exit 0, and a directory run reports those files as failed. - BREAKING — translate:
--tag-handlingnow pinstag_handling_version=v2instead of letting the API pick, so--tag-handling xml/htmloutput may differ from previous releases; pass--tag-handling-version v1to keep the old behaviour, which always wins over the default. Requests without--tag-handlingsend no version, and cached translations from earlier versions are retired on first open, so no tag-handling entry can be served stale. - BREAKING — translate/sync: Two exit codes move for files containing an empty string value:
deepl translate <file>exits 0 and writes its output where it used to crash with exit 1, anddeepl syncexits 0 where it used to exit 12 on every run forever. A rate limit part-way through a large structured file now exits 3 rather than 1, so a CI wrapper that treats 3 as retryable starts retrying it. - BREAKING — write/correct:
--check --format jsoncan now exit 0 where it always exited 8, and writes a payload to stdout. The verdict was computed against a rendered JSON document, so no input could pass it — a gate built on it was either unconditionally red or green only because a later step ignored the code, and it now returns the truthful answer. Anything reading stdout on this path finds a JSON object where it found nothing; text mode is unchanged. - BREAKING — write/correct:
--alternatives --format json --output <file>writes the alternatives JSON payload where it wrote the numbered text list. To put improved prose in a file, leave--formatat its default. Text mode is unchanged. - BREAKING — hooks:
deepl hooks list --format jsonreports a state string per hook —"installed","modified","unverified","not-installed"— instead of a boolean, so a truthiness test now passes for every state and must be replaced withstate === "installed". The text output gains the same distinction, a hook you customized by hand reports!from then on since its body no longer matches the hash recorded at install, andGitHooksService.list()returns those states whileisInstalled()is unchanged. See Security. - BREAKING — types:
WriteLanguagemembers are lowercase —'en-gb','en-us','pt-br','pt-pt','zh-hans'— so a literal in the old casing no longer compiles, andWriteImprovement.targetLanguagewidens fromWriteLanguagetostringbecause the API echoes that field in its own casing.SyncTmsConfigdrops the three removedtmskeys, so a consumer setting them stops compiling rather than being ignored at runtime. See docs/MIGRATION.md. - sync:
--format jsongains skip reasons and fields.sync pullcan reportshared_target(a target another sync configuration's lockfile accounts for),plural_entry(one exported string cannot fill a plural entry's forms),unusable_targetandkey_collision;sync pushcan reportuntranslated(a PO or XLIFF key not yet translated, previously uploaded as its own source text) andneeds_review. All of these also appear in the(N skipped: …)summary line, are excluded frompulled/replaced, and get no lockfile entry.sync pull --format jsongainsreplacedanddryRun, its text output gains a line naming a non-zero replaced count, and each locale insync status --format jsongains aneedsReviewcount. - sync: Pulled keys no longer carry
review_statusin.deepl-sync.lockat all, so anything readingreview_status === "human_reviewed"to find reviewed strings stops matching pulled entries — an absent field means "unknown", which is all the export response supports. See Security. - sync:
sync statusreports lower coverage for PO and XLIFF projects. A#, fuzzyPO entry and an XLIFF reviewstatenow count as needing review rather than complete, so a project reported at 100% drops to the share actually shippable — which is whatmsgfmthas reported all along — andsync pushreports a correspondingly lower pushed count.deepl syncalso writesstate="translated"on an XLIFF target whose translation it replaced, where it used to leave the old value; a target that carried nostateis written exactly as before. Nothing is re-translated or re-billed andsync --frozenstill passes. TheneedsReviewexplanation no longer describes gettext alone to users of a format that has no#, fuzzy. - sync: A project that sets
sync.max_characterscan be refused where it previously ran, anddeepl sync --dry-runcan report a larger character estimate for the same input, because both now count repair work — keys the lockfile calls translated that the target file no longer holds, which a real run translates and bills. Raise a cap that was tuned against the old under-count to the number--dry-runnow reports.--dry-runstill writes nothing and still exits 0. - sync: Backups are written as
<file>.deepl.bakinstead of<file>.bak, and the stale-backup sweep considers only the.deepl.baksuffix, so a user's own*.bakfiles are never touched. The sweep also no longer re-creates a deleted target file nor restores one over an existing file — it only ever deletes. Migration:.bakfiles from earlier versions are no longer swept or restored; delete leftover<target>.bakfiles manually if desired. - translate: A glossary referenced by name is checked against the requested language pair before any translation request, failing locally at exit 7 with what the glossary actually covers (
Glossary "my-terms" does not support the requested language pair/Glossary covers en→es; requested en→de.) instead of reaching the API as a UUID the user never typed. This costs no extra request. Matching is per dictionary, so a multilingual glossary holding en→es and de→fr does not cover en→fr, and every target of a multi-target run must be covered; both sides are compared on their base language, so a de→en glossary covers--to en-us. A glossary passed as a UUID, or one the API reports with no dictionaries, is left to the API. - languages: The DeepL API is now the authority on which languages exist, not the CLI's bundled list. Validation defers to the API: a well-formed language code the bundled list does not contain is sent to the API rather than refused locally, across
translate,syncand language values in the config file, while input that is not shaped like a language tag still fails fast with a pointer todeepl languages. The listing is API-driven:deepl languagesrenders the union of the API response and the bundled list. The list is generated:npm run generate:languagesrewrites it fromGET /v3/languagesandnpm run check:languagesfails on drift, and the core/regional/extended tiers are derived in the same pass (glossary support separates extended from the rest, source usability separates core from regional), reproducing the previously hand-assigned tiers exactly. No command line changes. - write: The Write API's 14 target languages are generated rather than hand-maintained, closing the last hand-kept language list, and the
WriteLanguagetype is derived from the generated list so a language added upstream widens it on regenerate. The generated list is byte-identical to what was there.write/correctstill check the code locally and name every valid option, but a code that is shaped like a language tag and simply is not in the snapshot is now sent to the API with that list as a warning rather than refused — nothing in CI regenerates the snapshot, so refusing outright made a newly added language unreachable. Malformed input is still rejected locally. The documented style/tone support table is unchanged and still maintained by hand, because it records what the API accepts rather than what its metadata claims. - types: The published
Languageunion is derived from the generated language snapshot instead of being written out by hand, so it can no longer fall behind it (it was four codes behind:de-ch,de-de,fr-ca,fr-fr).ENTRIESis generatedas const satisfies readonly LanguageEntry[]and the union derives from its codes, exactly asWriteLanguagederives fromWRITE_TARGET_LANGUAGES. The union only ever gains codes, so nothing that compiled before stops compiling; runtime validation still defers to the API, so the union describes what the CLI can name offline.LanguageEntry's fields are nowreadonly, so an accessor's result can no longer mutate the registry for the rest of the process. - languages: Ten display names changed to match the API, a consequence of generating the list:
ckb→ Kurdish (Sorani),es-419→ Spanish (Latin American),gom→ Konkani,kmr→ Kurdish (Kurmanji),my→ Burmese,nb→ Norwegian (bokmål),pam→ Kapampangan,st→ Sesotho,zh-hans→ Chinese (simplified),zh-hant→ Chinese (traditional). Only offline output changes — with an API key configured,deepl languagesalready took names from the API. Codes are unaffected; only output that scrapes display names changes. - api: Language listings migrated from the formally deprecated
GET /v2/languagesandGET /v2/glossary-language-pairstoGET /v3/languages(resource=translate_text/resource=glossary/resource=write). Command output is unchanged: source/target lists derive from the v3usable_as_source/usable_as_targetflags, glossary pairs from the source×target cross-product (verified identical to the v2 pair list), and the[F]formality markers from the per-languagefeaturesmatrix. A language whose features the response does not describe is left unmarked rather than marked unsupported, so the[F]legend cannot appear with no[F]beneath it. - cli: The primary human-readable reports of
sync status,sync validate,sync audit,sync initandauth showprint to stdout, sodeepl sync status > report.txtanddeepl auth show > key.txtcapture output instead of producing empty files. Diagnostics, warnings and progress stay on stderr, and--format jsonstdout purity is unchanged. - glossary:
createandshowrender the creation timestamp as a locale-independent ISO string, and thecreatesuccess line prints to stdout, matching the documented output instead of a locale-dependent date on stderr. - hooks: The installed pre-commit hook now actually validates translations — with a
.deepl-sync.yamlpresent and the CLI on PATH it runsdeepl sync validateand blocks the commit on validation errors, with a--no-verifyhint. It was previously a no-op that grepped staged files and always exited 0. - perf: CLI startup no longer eagerly loads the HTTP client (axios) or the format-parser stack (yaml, smol-toml): the API URL constants moved to a dependency-free module and
sync init's--file-formatchoices are filled lazily. Measured on--version: ~144 ms → ~80 ms median. Help output and invalid-value errors are unchanged. - perf: YAML reconstruction indexes every string slot in a single document walk instead of calling
setIn/deleteInper key, scaling roughly linearly with file size (~3.6 s → ~170 ms for a 16,000-key file). Batched deletion also fixes removing several items from one sequence shifting indices mid-iteration and deleting the wrong entries. - batch: Plain-text batch translation reads, translates and writes one API batch at a time instead of loading every file into memory up front, so memory stays proportional to a single batch. Batch grouping also measures the form-encoded body size that the API's 128 KiB limit applies to, rather than raw UTF-8 bytes, so CJK-heavy batches are split correctly instead of being rejected server-side.
- deps:
commander14.0.3 → 15.0.0, which is ESM-only and requires Node >= 22.12.0, so it was unmergeable until the Node 24 baseline landed. - ci: The test matrix, release workflow and security workflow target Node 24 (previously 20 and 22), and
.nvmrcpins Node 24 to match — it still read20, sonvm usehanded developers a runtime that cannot loadnode:sqlite. - build:
npm run buildruns acleanstep first, removingdist/andtsconfig.tsbuildinfobefore compiling, so a file rename can no longer ship stale artifacts innpm packoutput. - docs: The README documents three install paths — Homebrew (
brew install deepl/tap/deepl, which brings its own Node), npm (npm install -g @deepl/cli) and from source — with an explicit Node.js 24.15.0 prerequisite replacing thebetter-sqlite3native-compilation caveat. Install strings are updated acrossdocs/SYNC.md, four example scripts,examples/README.mdand the git-hook template insrc/services/git-hooks.ts.CONTRIBUTING.mdstates the Node 24 development prerequisite,SECURITY.md's supported-versions table reflects that only 2.x is a published line, and six staleDeepLcomGitHub URLs now point at theDeepLorg. - tests: A fast-check property suite (
tests/property/) enforces round-trip laws across all 11 format parsers — translated values survive reconstruct/extract intact, re-applying the same translations never changes the file, and an identity sync is a fixed point — plus preservation laws for the placeholder and ICU utilities. Runs are seeded-random with 200 cases per law (FC_NUM_RUNSoverrides;FC_SEED/FC_PATHreplay a recorded counterexample). It found the TOML U+2028 corruption and the.propertiesleading-space loss fixed in this release.
- BREAKING — sync:
tms.auto_push,tms.auto_pullandtms.require_revieware gone from the config schema. All three were on thetms:allowlist and indocs/SYNC.md, and no code read any of them, so a review gate configured throughrequire_reviewwas doing nothing. Each now fails config load with aConfigError(exit 7) naming it —tms.require_review was never implemented and has been removed— rather than as a generic unknown field, which would read as a typo.require_reviewis not implementable from this side, since the documented export contract is a flat{ key: value }map with no per-entry review flag; usedeepl sync pull --dry-runto preview a pull instead, and rundeepl sync push/deepl sync pullexplicitly in place of the auto flags. - BREAKING — cli: The
--enable-beta-languagesflag ontranslateis gone. The API deprecated the underlying parameter as having no effect — beta languages are part of the regular language set — so the flag had become a silent no-op. Scripts passing it exit 6 with an unknown-option error; remove the flag. - BREAKING — sync:
deepl sync init --source-langand--target-langs, the deprecated aliases introduced in 1.x, are removed and fail witherror: unknown option(exit 6). Use--source-localeand--target-locales.deepl translate --target-langis unaffected — it is the API's wire name, not a deprecated alias. - usage: The dedicated "Speech-to-Text Usage" section (text output), the "Speech-to-text" row (table output) and the
speechToTextMilliseconds*fields they read are gone, following the API's deprecation ofspeech_to_text_milliseconds_count/_limit("Always returns 0"). Voice usage remains visible in the Product Breakdown, which reads live per-product minutes; the Admin API's per-keyspeech_to_text_millisecondsusage limit is a different, still-current field and is unaffected. - deps:
better-sqlite3and@types/better-sqlite3. The production dependency tree no longer contains any native addon, removing the whole class of ABI-mismatch failures (ERR_DLOPEN_FAILED/NODE_MODULE_VERSIONafter a Node major upgrade), a 1.9 MB platform-specific binary, and the C++ compilation-toolchain requirement for installs from source. The cacheless-degradation safety net remains: a runtime whosenode:sqliteis unusable warns once and runs uncached rather than crashing, and never touches the cache database. - deps:
inquirer, which no source file imported. - repo: The
VERSIONfile — nothing read it, sincedeepl --versionreportspackage.json's value, so it was a second hand-edited source of truth that could only drift — and.npmignore, which was dead weight because thefilesarray governs what is packed. - build: Source maps and declaration maps are no longer emitted. They were already excluded from the published package, so emitting them only left dangling
sourceMappingURLcomments in the shipped files, giving consumers unresolvable stack frames and broken go-to-definition.
-
sync: Two concurrent
deepl syncruns in one directory can no longer both believe they hold the process lock. Reclaiming a pidfile already proven stale renames it aside and then confirms it is the same file before deleting it, but that confirmation compared inode and device only — and an inode number is reused once its file is unlinked, which is exactly what a sync winning the race does when it replaces the pidfile. On ext4 the freed inode comes straight back, so a winner's live pidfile compared equal to the stale one it replaced and was deleted, leaving both runs writing the same target files and the same lockfile. Identity now also requires the recordedpidandstartedAtto match, which a different holder cannot satisfy. The behaviour was filesystem-dependent: APFS never reuses inodes, so this reproduced on Linux only. -
cli:
deepl completion fishno longer emits a broken line for a command or option whose description contains a backslash. Descriptions were escaped for the surrounding fish single quotes by replacing'only, so a trailing backslash escaped the closing quote and ran the rest of the generated line into the description. Backslashes are now escaped first, then quotes. The bash and zsh generators use the POSIX'\''form and were unaffected. -
sync:
translation.locale_overrides.<locale>.model_typeis now applied. The key was on the config allowlist and validated per locale againsttranslation_memory, but the translator only ever read the top-leveltranslation.model_type, so the per-locale value was silently dropped — and the validator's own error message named that inert scope as the remedy. A locale that configures translation memory per locale therefore got requests the TM could not be applied to, silently, and was billed for them. The override now resolves exactly like its siblings (formality,translation_memory_threshold,custom_instructions,style_id): the per-locale value wins, otherwise the top-level one applies.SyncLocaleOverridesalso declares the field, and — as with those siblings — a per-locale override now takes precedence over--model-type. -
sync: A per-locale
translation_memorythat inherits a non-quality_optimizedtop-levelmodel_typeis now rejected at config load (exit 7) instead of being accepted and sent to the API. The pairing check only looked at amodel_typewritten inside the same override, so the mirror case — TM set per locale, model type inherited — passed validation and produced the same silent billing hazard. The check now evaluates the effective value for that locale and names where the offending one came from. -
sync: The first
deepl syncover an already-translated project no longer overwrites every reviewer translation with machine output. The carry-forward that protects a reviewed translation was wired into thecurrent-key path only, so with no.deepl-sync.lock— first adoption, or any CI checkout where the lockfile is gitignored — every key arrived asnewand was re-translated, re-billed and written over, dropping#, fuzzyand XLIFFstatemarkers at exit 0. Anewkey whose target file already holds a translation is now carried forward untouched and recorded in the lockfile. A target value equal to the source, or an empty one, is still translated, and--forcestill re-translates everything. -
sync: A reviewed PO or XLIFF translation is no longer replaced with source text. Both formats are bilingual, and every path that needed "the translation this target file already holds" read the source side (
msgid/<source>) — six call sites, includingsync pushandsync validate. Parsers may now override the target-side read (FormatParser.extractTranslations), PO and XLIFF do, and all six sites go through one helper. An emptymsgstr, a missing<target>and an empty<target></target>now read as untranslated, so such a key is translated rather than having the source pinned into it; in the monolingual formats an empty value is still a deliberate translation and is preserved.deepl sync pullhad the same blind spot in its merge base anddeepl sync auditmeasured consistency across source strings rather than translations. -
sync:
deepl sync pushno longer uploads the source language as a locale's translation — alocales/es/app.poholdingmsgstr "Hola amigo"used to push the English msgid, making the TMS authoritative for the wrong text.TmsClient.pushEntrynow takes the translation as a required argument rather than reading it offentry.value. -
sync: A target file that is on disk but cannot be read or parsed is left exactly as it stands instead of being re-translated and rebuilt from the source.
ENOENTis now the only read failure that means "absent"; any other errno and any parse failure mark that locale unusable before any translation is requested, so the run names the file and reason, reports✗ es: 0/2 keys, exits 12, bills 0 characters, and records a newly gained keyfailedfor the next run to retry.deepl sync pullroutes every unreadable target to the same outcome under a newunusable_targetskip reason;deepl sync pushalready propagated everything butENOENT. One reader (readTargetFile) now answers absent / usable / unusable for all four call sites at no extra I/O. -
sync: A target file that becomes unreadable between the pre-read and the write-time re-read is no longer reclassified as absent and overwritten with a source-derived file, with no backup taken. The reconstruct template now comes from the same guarded read, so an unreadable target aborts that locale exactly as the pre-read would. A target holding only whitespace is now classified as present-but-empty rather than unusable, since it has no translations to lose and parsing it would fail.
-
sync:
deepl syncanddeepl sync pullno longer delete another sync configuration's translations from a target file both configurations write — a flip-flop where each run deleted the other's keys and re-billed its own, both reporting success at exit 0. A run about to delete keys now looks for another configuration whose lockfile records those exact keys for the same locale, and leaves the file exactly as it stands:deepl syncfails that locale (nothing written, nothing billed, keys recordedfailed, exit 12) anddeepl sync pullskips it undershared_target, both naming the other lockfile, the shared keys and the remedy. Keys added to a locale file by hand are unaffected and still pruned with the usual warning. -
sync: A bucket whose source files resolve to the same target path is refused at the bucket walk, before any translation request, with an error naming both source files, the shared target and the locale. Previously each file's rewrite treated its own keys as the complete key set, so the files deleted each other's translations while every run reported success and billed again. The suggestion deliberately does not offer
{basename}, which does not separate two files both calleden.json; omittarget_path_patternor split the bucket. Multi-locale (bilingual) buckets are exempt. -
sync: One failed translate batch no longer deletes that batch's existing translations from the target file.
translateBatchchunks at 50 texts and keeps going when one chunk fails, and those empty slots used to fall through to a barefailed++— so, becausereconstructtreats the entry list as the complete desired key set, the failed chunk's keys were removed from the file (and the run's.bakunlinked, since the run itself succeeded). A key the target already holds now keeps its translation, and is deliberately recordedfailedrather than counted as a success so the next run retries it. -
sync: The lockfile records what reached the target file, not what the API returned. Every key is now read back out of the content just written before the lockfile is updated; a key the file does not hold is recorded
failed, warned about by file, locale and key, shown as✗ es: 0/1 keys, and exits 12. Presence is the test rather than byte equality, and a deliberately empty translation is exempt; content a parser has just produced and cannot read back counts as holding none of its keys. -
sync: A string added to a source file after the first sync is no longer silently dropped by five of the eleven formats. From the second run on, the existing target file is the
reconstructtemplate, so a newly added key has no slot in it —properties,ios_strings,laravel_php,android_xmlandxliffdiscarded it while the lockfile recorded ittranslated, so no later run corrected it and the characters were billed. Each now writes the entry in the file's own layout, XLIFF 1.2 (trans-unit) and 2.0 (unit/segment) alike. Two cases are deliberately still not written, because doing so would mean inventing structure the source defines: a new<string-array>item inandroid_xml, and alaravel_phpkey whose parent array is absent from the target — both now recordedfailedrather thantranslated. -
sync: A plural entry a run carries forward keeps its plural forms. The carry-forward handed the writer the source file's plural payloads — empty
msgstr[N]for gettext, source-language<item>s for Android XML — so any run with other work to do destroyed the target's plural translations at exit 0, on all three affected paths (translation, validation withholding, andsync pull). Those sites now omit the plural payloads so the parsers keep the file's own forms, and Android's writer keeps a<plurals>element verbatim for an entry handed over without per-form translations. A key absent from the entry list is still deleted, and a plural entry whose source text changed is still re-translated with fresh forms. Seedocs/SYNC.md"A plural entry carried forward". -
sync: A
#, fuzzyflag on a carried-forward PO entry survives a run that rewrites the file for a sibling key. The comment replay strippedfuzzyfrom every entry it emitted, so any sync with other work to do removed a reviewer's "do not ship" marker andsync statusthen reported 100% again. The flag is now stripped only when the run writes different content over the entry — comparing the msgstr and everymsgstr[N]— and a carried entry keeps its comment lines byte for byte (a#, fuzzy, python-formatline is no longer re-joined). -
po: A wrapped (continuation-line) plural form survives a run that only rewrites its entry for a sibling key.
reconstructre-emitted the first line of a form it was keeping and swallowed the continuations, collapsing a reviewed long form to an empty first line and leaving the entry permanently unwritable — a lossmsgfmt --statisticsdoes not reveal. Continuations are now kept for a form being carried and still dropped for one being replaced. -
sync: A translation the engine corrupted is withheld from the target file instead of being written and recorded as done. The placeholder/ICU validator ran after the write, the lockfile update and the backup cleanup, and its results were counted but never acted on; the new-locale backfill path was not validated at all. An
error-severity result (a lost placeholder, a rewritten ICU bracket or selector) is now decided before the write on both paths: the key is withheld, counted failed and recordedstatus: "failed", so the run reads✗ de: 1/2 keys, exits 12 and the next run retries the key. A withheld key carries whatever the target file already held rather than being deleted, plural write-backs are skipped for it, warnings still pass through and are still written, andvalidation.fail_on_errorkeeps itsfalsedefault and its documented meaning. -
sync: A failed backfill for a newly added locale no longer writes the source text in as the translation, which made those strings count as existing translations and be skipped on every later run —
sync statusreporting them missing whiledeepl syncrefused to retranslate them, permanently. The backfill now pushes nothing, so the key stays out of the target file, and nothing is recorded in the lockfile for it either. -
BREAKING — sync: Untranslated source text is never written into a target locale file and recorded as a translation. Where a key's source was unchanged, the lockfile claimed a translation and the target file supplied none — the file was deleted to force regeneration, or held an empty value — the source string was written through as the "translation" and kept its
translatedstatus, so no later run corrected it. Such a key is now re-translated, with a verbose message explaining why; a present-but-deliberately-empty translation is preserved. Behaviour change: deleting a target locale file now costs a real, billed re-translation instead of being backfilled with English. -
sync: A target file that has lost translations the lockfile records as translated is no longer reported as 100% complete —
sync status,sync --frozenandsyncitself all worked from the lockfile and never opened the target file, so a bad merge, a partial checkout or a hand deletion read as complete on every run. For every key recordedtranslatedagainst the current source, the locale's target file is now read; a key it does not hold is countedunwritten, its own category distinct frommissing(absent from the lockfile) andoutdated(recorded against an older source) and never counted as complete.sync statusnames the file and key,--frozenexits 10 naming the count, andsynctranslates the key again and writes it. New JSON fields:unwrittenper locale and top-levelunwrittenByLocaleonsync status, andunwrittenKeyson the sync result. Two deliberate exemptions: a key whose source value is empty, and a key already recordedfailedor against an older hash. The read is skipped for a locale the lockfile claims nothing for. -
sync:
sync statusno longer says the keys of an unreadable target file "are not in the target file", nor tells you to sync again — advice that bounced the user between astatusthat said to runsyncand asyncthat declined. Such a file now gets its own sentence naming it and the parser's reason, a project with both kinds of gap gets both sentences, and--format jsoncarries the reason as a newunusablefield on the locale'sunwrittenByLocaleentry. The--frozendrift line no longer asserts a cause it has not checked and points atsync statusfor the detail. -
sync:
--dry-runpreviews the run rather than the lockfile, in both directions it was wrong: it under-quoted repair work (reportingestimatedCharacters: 0for keys the next real run translated) and over-quoted work that was never going to happen (pricing a locale whose target file the real run refuses). Both halves now come from onefindTargetGapscall per source file: gap keys are added to the estimate and reported asunwrittenKeys, and a locale whose target file is unreadable is left out of the estimate and named in its own warning. The summary also states when a key recorded as translated could not be confirmed in the target file, so the estimate is not a number with nothing behind it. A dry run still writes nothing and still exits 0. -
sync: The
sync.max_characterscost cap no longer under-counts the work it is meant to cap. Its preflight used the same lockfile-only arithmetic as--dry-run— computing 500 characters for a run that bills 1,000 — so the two now quote from one function and a run the preview prices above the cap is a run the cap refuses. This costs one pass over the target files before the cap decides; projects that do not setmax_charactersare unaffected. -
sync:
--frozenno longer reads every target file twice — the target-versus-lockfile comparison is computed once per source file and reused. No output changes. -
sync: A run that lost most of its strings, or lost one locale of several, no longer exits 0. A locale counted as failed only when it translated nothing, so 10 keys succeeding and 50 failing was not a failed locale and CI went green over a truncated locale file. Any failure count above zero now fails the run (exit 12); a locale that translated nothing and recorded failures fails it; a locale with nothing to do is still a success.
--auto-commit, gated onresult.success, no longer commits an unsuccessful sync.docs/API.md's exit-code table now describes 12 as "at least one failed key". -
sync: The
Sync complete:summary names how many translations failed —Sync complete: 200 new (150 translations failed)— instead of reading as though every diffed key had landed, directly contradicting the✗ de: 50/200 keysline above it. The count totals every locale and nothing is appended when nothing failed. The progress stream'skey-translatedevents also fire only when that key'stranslateBatchslot came back non-null, so the number it reports agrees with the summary. -
sync: Staleness is judged per target locale.
computeDiffcompared only the entry-levelsource_hash, so once one locale was re-synced every other locale reportedcurrentforever and--frozencould not see it; and one locale's failure marked the key stale for all locales, sodeepl sync --locale dere-translated, re-billed and overwrote de's reviewed translation because es had failed. A key is now stale if any locale the run will actually touch lags the source or last failed,sync statusdistinguishes "the source changed" from "this locale's record lags" by re-checking the source hash and counts a failed translation asmissingrather thanoutdated, and locales absent fromtarget_localesare ignored while a locale with no entry at all is still treated as a new-locale backfill. -
sync: A gettext
#, fuzzyentry and an XLIFF review state are no longer counted as finished translations —msgfmtleaves a fuzzy entry out of the compiled catalog, so the string the CLI called done is one the application does not display. Each is now its ownneedsReviewcategory insync status(text suffix, 1 needs reviewplus a line explaining the marker and both ways out;needsReviewper locale in--format json), anddeepl sync pushskips it under a newneeds_reviewskip reason instead of uploading a draft as approved. Nothing is re-translated, rewritten or re-billed: removing the marker returns the key tocompletewith no API call, and clearing the translation has the next sync translate it afresh. XLIFF reads its states as an explicit claim only — 1.2'snewandneeds-*and 2.0'sinitialcount,translated/signed-off/final/revieweddo not, and an absent or unrecognised value counts as complete, so an existing project's coverage does not move.state-qualifier(1.2) andsubState(2.0) are not read, andsync --frozendeliberately still passes. -
sync: A
stateattribute on an XLIFF target the CLI has just written now describes what the CLI wrote. A source exported with<target state="needs-translation">placeholders — what most CAT tools produce — yielded a target file whose every unit claimed it still needed translating. Such a state becomestranslatedwhen reconstruct replaces the target's content, asigned-offstate written over is downgraded the same way rather than claiming human approval for machine output, an element carrying nostatestill gains none, and astateon a unit whose translation is unchanged is never touched — which is what keeps a reviewer'sneeds-review-translationalive through a run that translates a sibling key. -
sync: The startup stale-backup sweep no longer deletes a crashed run's backup, so recovery is no longer limited to
sync.bak_sweep_max_age_seconds(default 300) after the crash. A stale backup is now deleted only when the file beside it already holds the same bytes (sizes compared first, contents only when they agree, so the common case still costs onereaddir); one whose target has diverged or is gone is kept however old it is and reported once per sweep, naming the file. Retention is bounded at one file per target, andbak_sweep_max_age_secondskeeps its meaning for redundant backups. The fix is insweepStaleBackups, which every caller shares. -
sync: A backup left behind by a crashed run is no longer overwritten by the run you start to recover from it — the "already backed up" guard was a per-process
Set, so the recovery run copied machine output over the only surviving copy of the user's file and then unlinked it on success. The copy now usesCOPYFILE_EXCL, and onEEXISTthe existing backup is left alone with a warning naming it and is deliberately not tracked as this run's, so the success path does not unlink it either. -
sync: Ctrl-C during a sync restores the files it had already overwritten instead of deleting their backups, and reports what it undid (
Interrupted — restored 1 file(s) from backup: locales/de.json. No lockfile was written, so nothing was recorded; re-run to translate again.). Each backup is copied back over its target before being removed, so an interrupt during the restore leaves the backup rather than nothing. The restore is synchronous, because the exit is deferred by onesetImmediate. A successful run still unlinks from its own path. -
watch: A watch pass that fails, is cancelled or hits drift now restores the targets it had already rewritten instead of deleting their backups —
WatchControlleronly ever unlinked what the backup tracker held, so a two-locale pass where the second locale was refused left the first holding machine output, its.deepl.bakdeleted, no lockfile written, at exit 0. Each file is copied back before its backup is removed, and a backup that cannot be restored is kept with a warning naming it. A pass that completes still removes its backups as before. -
sync: Two syncs can no longer run concurrently in the same directory. A stale pidfile is now removed only after taking possession of it with
rename(2)and confirming the captured inode is the one proven stale (a process that captures a different file puts it back), acquisition always goes through one guarded create so a lost race reports the running sync instead of a rawEEXIST, the retry loop is bounded at 5 attempts, and the payload is written to a private path andlink(2)ed into place so a live lock can no longer be read as malformed — and therefore stale — during the window it was being filled. -
sync: A pidfile naming a process this user cannot signal no longer refuses every later sync indefinitely.
kill(pid, 0)answers EPERM for a PID owned by another user, a recycled PID, or one from a container's PID namespace, and that was read as "alive" with no upper bound while the recordedstartedAtwas never consulted. Such a holder is now trusted only while its start time is within 24 hours and readable as a date (a time impossibly far in the future counts against it), after which the lock is reclaimed with a warning naming the PID and the reason. A holder the probe reports as genuinely running is never aged out — that case gets--break-lock, which the refusal message now names. -
sync:
sync resolvetakes the process lock,--dry-runincluded, and exits 7 with the same "Anotherdeepl syncprocess is running" message while one is held, leaving the conflict markers in place. Previously a concurrent sync silently erased its merge, both steps reporting success. Its write also goes through the atomic rename every other lockfile writer uses, so a crash part-way through leaves the previous lockfile rather than a truncated one (which reads as corrupt and costs a full re-translation), and a run whose lockfile changed on disk between its read and its write now says so while still recording what it did. -
sync: Each lockfile translation is written on a single line, so the smallest region
git mergecan produce is a whole translation. Field-per-line entries let git merge two clean hunks into a translation that existed on neither branch —review_status: human_reviewedfrom one side carrying the other'stranslated_at, marking machine output as human-approved at exit 0 with no conflict reported — and madesync resolve'stranslated_attie-break unreachable, so every field took thekept ours: scalar conflictpath and discarded newer human translations. A region's trailing comma is now removed before parsing and restored after, keys are sorted within the line,statsis written on one line too (its counts andlast_syncdescribe one run) and is recomputed fromentrieson read. Where the two sides disagree on the terminator, the region still falls to the length heuristic rather than risk invalid JSON. Upgrading reformats every existing lockfile on the next write; it is formatting only and no entry's content changes. -
sync:
sync resolveno longer keeps the local side of every conflict while reportingkept ours: neither side had translated_at. An entry holding atranslationsmap is now treated as a container and merged one locale at a time, which is where the timestamps live, and the report names the locale it decided (greeting.translations.de). A translation leaf is identified byhashas well astranslated_at/source_hash, and one side looking like a translation is enough to arbitrate the pair whole, so no field combination can be invented however degraded the other side is; a non-stringtranslated_atcounts as absent so another tool cannot steer the tie-break with a non-comparable value; and fields the two sides agree on are no longer listed as conflicts. Two decision reasons that stated untruths are reworded (kept ours: same translated_at <stamp> on both sides,kept ours: theirs had no translated_at), the resolved file is written back in canonical form so committing it cannot leave entries expanded for the next merge, and each parse-error fallback warning prints once with a relative path. -
sync:
sync resolveno longer warns about possible data loss on every ordinary lockfile merge.statssat two context lines belowgenerated_atand both change on every write, so git joined them into one region that opened insidestatsand closed outside it — not a member list, soJSON.parsefailed, the length heuristic decided, and the one signal that exists to make silent data loss auditable fired on every resolve, including two branches translating different keys. Writingstatson one line keeps the region a member list, and the same fixtures now reportgenerated_atandstats.last_syncas ordinary per-member decisions with no warning. -
sync: A lockfile entry whose i18n key is named
__proto__no longer vanishes on every write, and the key-sorting JSON replacer no longer drops it either. See Security for the full accessor rework. -
sync:
deepl sync pullno longer writes source-language text into a target locale file. The merge's final?? entry.valuefallback filled a key the export omits and the target lacks with the source string — indistinguishable from a translation — and, when the target file did not exist yet, wrote every untranslated key into the new locale as English. Such a key is now omitted from the entry list soreconstructleaves it out of the file; an empty string is a translation and is still preserved. The pulled value still wins over the local one, since neither side of the export contract carries a timestamp, but the overwrite is no longer silent: pull counts the local translations it replaced and names the count (Replaced 1 existing local translation with the TMS version. Use --dry-run to preview a pull before it overwrites local edits.), with--verbosenaming each key and file. -
sync:
deepl sync pullkeeps the tabs and newlines of a multi-line translation instead of deleting them and fusing the adjacent words. The sanitizer stripped the whole[\x00-\x1f\x7f]range, including the three C0 bytes every format either escapes or legally emits; tab, LF and CR now survive to the per-format writer, which escapes them, while every other C0 byte and DEL is still stripped so a raw ESC cannot reach a locale file. -
sync:
deepl sync pullrecognises a gettext plural entry that declaresmsgid_pluralbefore it holds anymsgstr[N], instead of judging plurality from metadata that only appears once forms exist — which had it treat the entry as an ordinary key and record a translation it had not applied. -
sync:
deepl sync pullno longer discards existing translations for keys named afterObject.prototypemembers.sanitizePullKeysResponse's accumulator now has a null prototype and keeps it through the merge, andmergePulledTranslationstests membership withObject.hasOwn, so a source key calledtoString,constructor,valueOf,hasOwnPropertyor__proto__no longer resolves to an inherited function that beat the real translation and then vanished from the file while the lockfile recorded ittranslated. -
sync: An unreachable TMS is reported as a network failure (exit 5) naming the request —
TMS request failed: PUT https://tms.example.com/api/projects/p/keys/greeting: ECONNREFUSED— plus a line pointing at theserver:URL in thetms:block, rather than a bareError: fetch failedat exit 1 for a refused connection or an unresolvable host while a hung server exited 5. The replay policy is deliberately unchanged, and aNetworkError, a 401, a 500 and a timeout each keep their own message. -
sync:
TMS server URL must use HTTPSnow echoes the URL and points athttp://localhost, which reaches a server bound to::1,0.0.0.0or127.0.0.1. The rule is unchanged: plainhttp://is waived forlocalhostand127.0.0.1only, the same pair the DeepL API base URL waives. -
sync: TMS request URLs are built with the URL API — a trailing slash on
server:no longer produces a doubled separator, a base path is preserved, and a URL with a query string or fragment is rejected instead of silently truncating the API path. TMS timeouts exit 5 instead of 1, TMS error messages redact credentials embedded in the server URL, anddeepl sync pullenforces its 32 MiB response cap while reading the body rather than after parsing it. -
sync:
deepl sync auditno longer reads files outside the project root. Audit is driven by.deepl-sync.lockkeys rather than globbed paths, and it joined them to the project root and read them with a barefs.readFile, so a lockfile key of../secretplace/en.jsonprinted that file's string values ininconsistencies[].translationsat exit 0.assertPathWithinRootnow runs before the read and a violation ends the command at exit 6; a lockfile entry whose path merely fails to resolve for a locale is still skipped. -
sync:
deepl sync auditno longer uses the lockfile's source hash as a stand-in for a translation, which reported divergent translations as consistent and identical ones as an inconsistency displaying a hex hash. Targets that cannot be read are listed separately in a new additivemissingTargetsfield in--format json. -
sync:
sync.limits.max_file_bytesapplies to target files, not only source files — a target is the file a hostile or corrupt checkout controls, and it was parsed and rebuilt at any size. An oversized target is now reportedunusable, with the reason naming the limit, rather than treated as empty (which would re-translate the locale in full and overwrite the file). The size is checked on the content that was read, since the cap exists to bound the parse and comparison work. -
sync: An
include/excludepattern with a nested-quantifier extglob is refused at config load, naming the offending construct and pointing at@(…), instead of wedging the process for minutes.*(…)and+(…)compile to a repetition, so an unbounded wildcard inside one hands picomatch a nested quantifier — the six-character+(a*)btook about 50 seconds against a 40-character directory name, and both pattern and directory names come from the checkout.@(…),?(…)and!(…)are not repetitions and are unaffected, so ordinary patterns still work. -
sync: A run that removes a hand-added key from a locale file now says so. Every run that rewrites a locale drops whatever the target holds that is not in the desired key set; for a key neither the source nor the lockfile accounts for that is someone else's data, and it was deleted with no mention anywhere while the
.deepl.bakwas unlinked on success. Such keys are now named in a warning pointing at both remedies — add them to the source, or recover them from the backup before the next run. The prune itself is deliberately unchanged. -
sync: An invalid
--concurrencyno longer makes a sync silently do nothing while reporting success —--concurrency abcproducedNaN, which survived defaulting and started zero workers.--concurrencyand--debouncenow reject non-positive and non-numeric values at the boundary,sync.concurrencyis validated in config, andmapWithConcurrencyclamps to at least one worker. -
sync: Subcommands honour
--locale(status,validate,export, which listed or exported every configured locale when the flag trailed the subcommand name) and--sync-config(status,validate,export,audit,resolve,push,pull, which silently used the auto-detected.deepl-sync.yamlinstead). A--localevalue that is not intarget_localesnow exits 7 with aConfigErrornaming the offending and configured locales, rather than exiting 0 having translated nothing.deepl sync init --sync-config <path>writes the config at that path and checks the already-exists guard against it, and.deepl-sync.yamlis written atomically. -
sync:
--auto-commitrecognises its own translation output rather than only the files a given run wrote, so a translation left on disk by an earlier refused run is committed once the genuinely unrelated changes are dealt with instead of being refused forever; and in--watchmode a trigger that translated nothing no longer skips the checks and reports success while a commit is owed. Re-running after a refusal now reports the refusal identically instead of exiting 0 with nothing committed. Staging is driven by what is actually dirty, so a rewrite producing identical bytes no longer attempts an empty commit, and ownership is derived from the lockfile's tracked source files matched per bucket so one bucket'starget_path_patterncannot claim another's output. -
sync:
deepl sync --frozenreports an accurate key count when drift is caused by a newly added target locale, where it readSync drift detected: 0 new, 0 stale keys.; the message also surfacesdeletedKeysand mentions only nonzero categories. The drift exit code (10) is unchanged. -
sync: Every ICU block in a message is protected, not just the first.
parseIcufound one block and pushed the entire remaining suffix as ordinary prose, so second and later blocks were submitted to the engine as translatable text — and an engine translatingotheryields a message with nootherbranch, which throws at render time. Detection now walks the whole string, emitting each run of prose as its own segment and each block through the same brace-counting parser; adjacent blocks, three or more blocks, and nested ICU are covered. The documented safe fallback now applies to the whole message: a string holding any block that will not parse passes through untouched rather than half-protected. -
sync: ICU plural/select messages with text around the block,
offset:N, and single-quote-escaped braces ('{','}','','#') are recognised and preserved instead of falling back to raw machine translation, which demonstrably translates the format keyword and the selectors. Detection matches a block anywhere in the string and the prose on either side becomes a translatable segment rather than being dropped from the reassembled output.sync validateplaceholder checking is ICU-aware, so a translated branch body no longer raises a spuriousExtra placeholders in translation. -
sync: ICU structural damage is detected instead of reported as passing — brace counts and nesting depth are identical when the engine translates the keyword, a selector or the argument name, so
fail_on_errornever tripped on a message that no longer renders. Validation now compares the parsed argument/format-type headers and the selector keyword sets, tolerating reordered selectors. A failed ICU segment is marked failed and retried rather than yielding a part-English message reported as a success, andreassemblethrows on a translation-count mismatch instead of filling gaps with empty strings that render nothing for that category. -
sync: The internal ICU marker is no longer submitted to the API and billed as a text of its own — one extra request per batch containing an ICU string. The slot now holds nothing to translate, and
reassembleIcuoverwrites every mapped index so the blank cannot reach a target file. -
sync: Two keys whose placeholders protect to the same text no longer receive each other's variables.
Hello {name}andHello {user}both becomeHello __VAR_0__, whichtranslateBatchdeduplicates by design — but the same result object was assigned to every index and the restore loop editedresult.textin place, so the second key was written with the first key's variable (Deleted %s files/Deleted %d filesproduced a specifier that no longer matches its argument). Each index now gets its own copy of the result and both restore loops replace their entry instead of editing it.deepl translatewas never affected. -
sync: Template literals containing regex metacharacters in scanned source code (
t(`item(${i}`)) no longer abort context resolution with a rawSyntaxErroror silently mismatch keys — metacharacters are escaped before the scan pattern is compiled. -
po: A catalog written without blank lines between its entries is no longer collapsed into one entry. The separator is a gettext convention, not a requirement, but both halves of the parser treated it as the only thing that ends an entry:
extractreported a single key,reconstructwrote that one translation into everymsgstrit walked past including the header's, an entry carrying#:comments was lost outright, an obsolete#~run deleted the header, a plural entry lost itsPlural-Formsrule, andextractTranslationsreported only the last entry as translated so every other reviewed msgstr was re-translated and re-billed on every run. An entry now ends where gettext ends it — at the first line that is not a continuation of its translation — in both the reader and the writer, each of which was proven insufficient alone. Layout is untouched: a catalog that arrived without separators keeps that shape. -
po: Adjacent string literals on one line are concatenated as gettext defines them —
msgstr "Hola " "mundo"is the stringHola mundo, where the inner quotes used to be read as content and then escaped into the translation permanently. Applies tomsgidandmsgctxtas well; a quote genuinely part of the string still decodes as content, and a malformed value (unterminated quote) is returned untouched rather than mangled. -
po: An escaped carriage return survives a round trip instead of gaining a backslash on every run —
quoteescaped CR to\rbutunquotehad norcase, so a carried entry's CR became\\r, then\\\\r, indefinitely.unquotenow decodes\r, making the round trip idempotent. -
po: A
msgstrcontaining U+2028 or U+2029 is readable. JavaScript's.excludes the line separators, so such a line matched neither scanner and the key was reported unwritten under a remediation no later run could satisfy. Escaping is not available here, since the PO escape set has no\uXXXX. -
po: An obsolete
#~block is no longer deleted along with the entry beneath it — gettext's retired-work region is an obsolete entry in its own right, not the following entry's comments. An entry's own#./#:/#,comments are still dropped with it. -
po: A plural key appended to an existing catalog keeps its
msgid_pluraland everymsgstr[N]. The append path wrote the singular entry shape, so a plural key added to the source after the target file existed hadngettextreturning the English source for every count while the lockfile recorded it translated. The path now emitsmsgid_pluraland onemsgstr[N]per recorded form in index order, with index 0 falling back to the entry's translation. -
po: Rebuilding a catalog is linear in the length of a comment block again rather than quadratic — the run of comment lines above an entry is taken with a single slice instead of
unshifting each line (80k comment lines: 437 ms → 11 ms). -
properties: A value ending in a backslash no longer deletes the entry after it.
escapeValuewrites a literal trailing backslash as\\, but the reader's continuation test had no parity check, so it consumed the following line and appended its raw text to the previous value — whichsync pushthen sent to the TMS under the previous key's name while the swallowed key was never pushed at all. The same misread hits a hand-written source on its first read, so a Windows path such aspath=C:\\was enough. Both continuation tests now count the trailing backslash run and continue only on an odd count. -
properties: A key containing an escaped
=or:is read back correctly, so the parser can read its own output.greeting:formal, written asgreeting\:formal, was split at the escaped colon so half the key was sent to the engine as the value and the real key never reached the target file; the rewrite path carried a second copy of the same pattern and truncated the key. Both now share one fragment that treats\Xas a single key character, a key's own bytes are preserved when its value is rewritten, and an ordinary key still splits at its first separator (a.b=x=y→ valuex=y). -
properties: Reconstruction escapes leading spaces in values (
\), which the value parser otherwise strips on the next read, so a translation beginning with a space no longer loses it on every sync.escapeValuealso emits all UTF-16 code units of a character rather than only the high surrogate, so an emoji round-trips as a complete surrogate pair. -
toml: A quoted key containing a dot is translated in place instead of being turned into a nested table.
"greeting.formal" = "Good day"is one flat key, but the entry-line regex excluded quoted keys, soreconstructpassed the line through untranslated and appended a new[greeting]table — the source kept its English while the lockfile recorded the key translated andsync statusread 100%. Quoted key segments are now matched with the logical dot-path derived throughTOML.parseitself, the key is rewritten in place keeping its quoting style, and insertion writes"greeting.formal" = …rather than inventing a table (attached only where a segment carries a literal dot, so staleness is unchanged for every other key). TOML also now usesassertDistinctKeys, so a file holding both"a.b" = …and[a] b = …is refused rather than having one translation written over the other. -
toml: A new key outside TOML's bare-key character set is written as a quoted key instead of invalid TOML.
${leaf} = valueand[${section}]were emitted unquoted, so a source key such aswith spaceproduced a document the next read refuses — and because the run that wrote the file is the run that broke it, every later sync refused the locale it had just created. Leaf keys and section-header components are now emitted bare where the character set allows and as quoted keys otherwise. -
toml: Reconstruction escapes U+2028/U+2029 in double-quoted values, with a literal-string value gaining one falling back to double quotes. Written raw these broke the entry-line scan on the next sync, which re-appended the key as a duplicate and made the third sync fail to parse the file at all. Found by the property-based round-trip suite.
-
toml: Multi-line (
"""/''') values survive a sync — only the opening line was emitted verbatim, so body lines that looked likekey = "…"were parsed as entries and deleted from the value while the never-marked key was re-appended at end of file, leaving a document that no longer parses. The whole block is now emitted verbatim and skipped, including the single-linek = """text"""form; multi-line values remain untranslated as documented. -
toml: New keys are written into the section they belong to. They were appended at end of file using the full dotted path while a
[section]header was still in scope, somessages.newkeyparsed back asmessages.messages.newkeyand was re-appended on every subsequent run. Keys are now inserted inside their own section block, with a new[section]header added only when that section is absent. -
android: A single-quoted
nameorquantityattribute is no longer invisible.<string name='greeting'>is well-formed XML and the scanners accepted either quote for every other attribute, so such an element was never extracted, never translated and never reported whilesync statusread 100% and the string shipped in the source language. All four scanners (<string>,<plurals>,<item quantity=…>,<string-array>) now capture the delimiter and exclude only the one in use, each element's quoting style is preserved on rewrite, and a following attribute is still not swallowed into the name. -
android: A
<plurals>element whose name looks like an array index (x.0, beside a<string-array name="x">) is no longer deleted —reconstructdecided what an entry was from the shape of its key, so with the entry's plural metadata stripped nothing claimed the element. A key that names a<plurals>element in the file is now treated as that element whatever shape its name has. -
android: XML entities no longer compound on every sync run.
extractnever decoded entities andescapeAndroidreplaced&last, soTerms & Conditionsbecame&amp;after one run and&amp;amp;after three. Entities are now decoded on extract via a single-pass decoder (so a literal&lt;decodes to<, not<) and&is escaped before</>; a three-run identity sync is a fixed point. -
android: A self-closing element (
<string name="x"/>) is no longer deleted together with the element that follows it, a value whose CDATA body contains</string>is no longer truncated on extract, and plural<item>attributes are preserved when a translation is written back. -
android, xliff: The key of an appended resource is guarded and escaped, not just its translation. Both new-resource append paths ran the control-character assertion on the translation and then interpolated the key straight into
<string name="…">/<trans-unit id="…">, so a control byte produced a file no XML consumer can read plus a live terminal escape ingit diff, and a key containing&,<or"broke the attribute outright. The key is now checked for control bytes (as are Android pluralquantityvalues) and escaped for an attribute context, andextractentity-decodes attributes so such a key round-trips. -
xliff: A
trans-unitorunitid containing an apostrophe is read in full, so two units no longer collapse onto one key. The scanner excluded both quote characters from the value, soid="label.don't"truncated tolabel.donand one fetched translation was written into both units, shipping one string's translation under another's id — unreported, since XLIFF is exempt fromassertDistinctKeys. The delimiter is now captured and the value excludes only that delimiter in both the 1.2 and 2.0 scanners. -
xliff: The review state of a rewritten element is read and updated on the real attribute rather than on a
state=sequence inside another attribute's value (note="compare state='final' …"), which madeextractNeedsReviewread the decoy and the rewrite corrupt it while the real state stayed attached to text the run had just replaced. Attributes are now walked as wholename="value"pairs, so an attribute merely ending instate(xstate=) is not mistaken for it either. Two sibling markers are handled at the same time:approved="yes"becomesapproved="no", andstate-qualifier(1.2) /subState(2.0) are removed rather than rewritten. Every marker is left untouched when the run writes the same text it found. -
xliff: Files carrying a
stateattribute are no longer mangled — the<segment>and<target>patterns required bare tags, so in 2.0extractreturned nothing andreconstructthen deleted every<unit>, and in 1.2 an existing<target state="…">was treated as absent so a second<target>was injected, yielding schema-invalid output holding the stale translation. Both elements now accept attributes and preserve them through a round trip. A CDATA section in a<note>between<source>and<target>is also no longer rejected; only CDATA inside<source>/<target>is unsupported. -
formats: Android XML and XLIFF parsing is linear in file size — both matched elements with a lazy pattern, so every opening tag without a matching close rescanned the rest of the file and a 4 MiB resource file took minutes. Android reconstruct also no longer rescans the whole file once per dotted key to identify
<string-array>members (2.7 s → 63 ms on a 3.6 MiB, 52,000-entry file). -
formats: CRLF-authored resource files are no longer invisible to the tool. Line-based parsers split on
'\n', leaving a trailing'\r'that defeated their$-anchored patterns, so a Windows-authored.poextracted zero entries andsync statusreported 0% coverage at exit 0 whilesync exportemitted an empty XLIFF; TOML instead appended duplicates until the file no longer parsed. PO, TOML, iOS.stringsand Java.propertiesnow all split on/\r?\n/. -
formats: YAML files using merge keys (
<<: *anchor) or aliases to anchored maps and sequences no longer fail at sync write-back withExpected YAML collection— extraction emitted paths through aliases that reconstruction could not apply. Aliased collections are translated at their anchor site and the references round-trip untouched. -
formats: Translating a key in an Xcode String Catalog no longer destroys that key's plural
variations— reconstruct replaced the locale's entire localization object with a singlestringUnit, discarding per-category translations the parser never surfaces as entries. Existingvariationsare preserved alongside the updatedstringUnit, and theLocalizationtype declares the field. -
formats: ARB (Flutter) files with a UTF-8 BOM are readable, matching the JSON parser, and an
.arbkey whose name collides with anObject.prototypemember (toString,valueOf,constructor,hasOwnProperty,isPrototypeOf,__proto__) is no longer dropped from the file it was billed for —key in datareported inherited members, so on the second run those keys were discarded while the lockfile recorded them translated and no later run retried them. Insertion now testsObject.hasOwnand writes through the sharedsetOwnMember(moved toutils/own-members.ts). -
formats: The JSON parser no longer pollutes
Object.prototypevia a__proto__key in a resource file, and round-trips prototype-named keys as ordinary data: membership is an own-property check and assignment goes throughObject.defineProperty, so a key legitimately calledtoStringtranslates like any other. The guard is now pinned by a test that fails ifdefinePropertyis replaced with plain assignment. -
formats: A Laravel PHP lang file that overflows
php-parser's unguarded recursion is skipped with a warning (Skipping lang/en.php: nesting depth exhausted the stack while parsing …) instead of ending the run withError: Maximum call stack size exceeded. -
sync, translate: A deeply nested JSON or YAML file no longer ends the whole run with
Maximum call stack size exceeded— a 16 KB file of 8,000 nested arrays sits far belowsync.limits.max_file_bytes, and in a sync the crash was worse than a crash, since work already translated and billed went unrecorded because the lockfile is written at the end.sync.limits.max_depth(default 32, ceiling 64) now applies to any parser that accepts one, via a new optionalwithMaxDepthonFormatParser, and always rather than only when the default was overridden; one file exceeding the cap is skipped with a warning naming the key path while the run finishes and records what it did. YAML is bounded differently, since its library blows the stack before any walker of ours runs: that parse diagnostic is recognised as a depth rejection, and aRangeErrorescaping any parser is caught at the same boundary. The directdeepl translate <file>path, which has no configured limit, gets a fixed ceiling of 100 levels. -
watch: Two watched files with the same name no longer write one translation on top of the other. The output path was built from the basename alone, so every
doc.mdunder the watched tree mapped to one<output>/doc.es.mdand the last translation to finish silently replaced the others, at exit 0, with the count still readingTranslations: 2. All three flattening paths — single-target, multi-target text, and multi-target structured (JSON/YAML) — now carry the source's directory relative to the watched path, and the loop guard still recognises a nested output directory as the CLI's own work rather than re-translating it. -
watch: A slower translation of older content no longer overwrites a newer one and leaves the output permanently stale — the debounce bookkeeping was dropped when the timer fired rather than when the translation finished, so an edit arriving mid-translation started a second translation of the same file and both wrote the same path in API-completion order. Nothing recovers from that state on its own, since the file will not change again. A file is now translated one version at a time: an edit arriving during a translation queues exactly one re-translation, which starts only after the running one has written. Coalescing also collapses an edit storm (six edits during one slow translation: 7 API calls before, 2 after). Translations of different files still overlap up to
--concurrency. -
watch: A file changed during its own translation is no longer translated twice concurrently — the debounce entry was deleted in the translation's
finally, removing whatever entry was current by then, usually a newer pending timer that could then not be cancelled. The entry is now cleared when the timer fires, and only if it is still the entry that timer registered. -
watch:
--auto-commitno longer loses most commits under an edit storm.WatchService.onTranslatewas typed=> voidand called withoutawait, so the git work ran outside the translation's concurrency slot, unbounded, and parallel auto-commits fought over.git/index.lock— six files edited at once produced 6 translations, 2 commits and 4 auto-commit failures, with three output files left untracked and one staged but uncommitted, at exit 0. The callback may now return a promise and it is awaited, so a rejection reachesonError, and the git work is queued oneadd/commitpair at a time (same harness after: 6 translations, 6 commits, 0 failures). Translations of different files still run in parallel. -
watch:
--auto-commitand--git-stagedact on the repository holding the files they name rather than the directory the CLI was started in. Every git invocation ran with no working directory of its own, so watching a path outside the current repository handed one repository's pathspecs to another's index: with the terminal elsewhere every commit failed withis outside repository at …, with the watched repository nestedgit addsilently did nothing, and with the working directory in no repository the session printed⚠️ Not a git repository, skipping auto-commituntruthfully and exited 0.--git-stagedfailed silently inside a single repository with no unusual layout at all, sincegit diff --cached --name-onlynames files relative to the repository root and the result was resolved against the process working directory. Both flags now resolve their repository from the path they act on —git rev-parse --show-toplevelanchored on the output directory for the commit and on the watched path for the staged snapshot, with every staged name resolved against that repository's root — and output paths are made absolute before the working directory changes, so a relative--outputstill commits the file it wrote. No case ever committed into the wrong repository's history. -
watch:
--git-stagedrecognises a staged file when git and the watcher spell its path differently. The comparison key resolved symlinked ancestors but nothing else, so a case-insensitive volume (whererealpathSyncdoes not case-fold) or NFC versus NFD produced different keys for the very same inode andwatch --git-stagedtranslated nothing, silently, at exit 0. Where the file exists the key is now its device + inode pair (lstat, so a symlink stays a different file from its target); a path with no file yet falls back to the resolved string. The same two-spellings defect had a second site: the loop guard's warn-once bookkeeping compared unresolved paths, so a session whose output directory is reached through a symlink warned about a file the CLI had just written itself, naming two spellings of one directory. Both sites now compare a canonical key with symlinked ancestors resolved and the final component left alone; messages still show the path as the user spelled it. macOS reaches this with no symlink of the user's own, sinceos.tmpdir()is symlinked. -
watch:
--auto-commitno longer reports a session failure when there was nothing to commit — re-saving a file whose translated bytes are unchanged stages nothing,git commit --onlythen exits non-zero, and the undifferentiatedcatchcounted that as a failure, exiting 12 although nothing went wrong. The staged state is now asked withgit diff --cached --quietrather than by matching git's "nothing to commit" wording, which a localized git translates, and a genuine failure is reported with git's ownstderrinstead of a bare exit-status message. -
watch: A source file whose name carries a target-language segment is translated instead of being skipped for the whole session —
pricing.es.mdunder--to eswas dropped with no request, no output and not even a📝 Change detectedline. The loop guard now also requires the file to be inside the output directory, which leaves the loop protection complete. In a same-directory layout a file the CLI did not write is still skipped, since the name is genuinely ambiguous there, but now says so once per file, naming the file and the output directory to move it out of; the CLI's own writes are skipped in silence. -
watch: The loop guard no longer warns about the temp file the CLI is writing through.
atomicWriteFilerenames from a<target>.tmp.<pid>.<random>sibling, whose name carries the output file's, so the session told the user to move a file this process had already renamed away. Such a path is now recognised as an in-flight write and ignored before the output-file check; and, conversely, a real document named like a temp sibling is no longer skipped — the check is a membership test against the in-flight set, with the name pattern honoured only for a path that no longer exists. -
watch:
deepl watchdebounces at the documented 500 ms and honourswatch.debounceMs. The command forwarded a debounce only when--debouncewas passed, so an omitted flag fell through to a third copy of the value at 300 ms while--help, the docs and the config schema all said 500, and the configured key was accepted bydeepl config setbut never read. Resolution is now flag, thenwatch.debounceMs, then a single exported default shared with the config schema.deepl sync --watchwas already correct. -
watch:
--glossarywithout a source language now exits 6 before the watcher starts, instead of starting a session that fails on every file change with a raw server message — the worst place for this, since the operator saw the failure once per edit. The check runs before the glossary name is resolved, so it costs no API call, and it honoursdefaults.sourceLangas every other command does, including on a directWatchCommand.watch()call, which previously refused a command the CLI accepted.syncneeds no equivalent, taking its source language from the requiredsource_locale. -
watch:
filesWatchedstatistics report the actual number of files under watch — seeded from the watcher's inventory once the initial scan completes and tracked on add/unlink — instead of always 0. -
translate: The CLI's own internal placeholder tokens can no longer be written into your text.
restorePlaceholderssubstituted every__VAR_n__it could find and left the rest alone, with nothing checking that the tokens the CLI injected came back — and re-casing an unfamiliar token is ordinary MT behaviour, so__VAR_0__returning as__ Var_0 __was printed, written to a file, or written into every file of a directory run, all at exit 0. All three paths now check the post-condition:translateexits 5 naming the variables it lost (The translation lost the placeholder {username}. … Nothing was written.) and a directory run fails only the affected files. The check runs before the cache write, and a poisoned entry from an earlier version is refused on read.syncis deliberately unchanged, since its validator already withholds and retries the key. -
translate: A source string that literally contains the CLI's own placeholder token is no longer corrupted, and a genuinely lost token is no longer reported as intact. Tokens came from a plain counter, so text carrying a literal
__VAR_0__could be handed the same token as a real{name}andreplaceAllbrought both back as{name}— which also made the loss check accept the literal copy as evidence the substituted token had survived. Tokens are now chosen to avoid any the source text already contains, for both the__VAR_and__CODE_families, and the low numbers are still used when the source carries none. -
translate: Code preservation survives a fenced block overlapping an inline code span (
` ```…`````), where the later pass wrapped an earlier pass's token and--preserve-code` then refused the translation at exit 5, blaming the endpoint for a token the CLI had nested itself. Restore now expands tokens in reverse insertion order, so one pass unfolds every level, and the loss check counts a token as surviving when it sits inside a later span that itself survived — in the sync validator as well as the translate-path assert. -
translate/sync: An empty string value in an i18n file is no longer mistaken for a failed translation.
translateBatchskipped an empty text but left that slotnull, the same value it uses for a failed request, sodeepl translateprinted a false1 of 3 translations failedand then crashed at exit 1 with no output file,deepl syncdeleted the key and recorded itfailedso every later run exited 12 forever, and an ICU message with an empty plural branch had the whole message withheld and its key dropped. Empty input now returns an empty translation withbilledCharacters: 0,translateBatchis declared(TranslationResult | null)[]so the compiler forces the remaining failure case to be handled at each of the five call sites,BatchTranslationServicerecords a missing per-index result as that file's failure, and theN of M translations failedwarning counts only real failures. -
translate:
--no-cacheis honoured for every structured i18n format — JSON, YAML, TOML, PO, XLIFF, Android XML, iOS.strings,.xcstrings, ARB,.propertiesand Laravel PHP — where it was accepted and silently ignored, so re-running the identical command with the flag sent zero requests and reproduced the previous output byte for byte, defeating the one remedy a user would reach for.translateBatchnow takes the sameserviceOptionsshape astranslate()and honoursskipCacheon both the read and the write, threaded throughtranslateFile,translateFileToMultipleandtranslateStringsInBatchesso single- and multi-target behave alike, and the bypass notice is emitted on the batch path.syncandbatchexpose no--no-cacheand are unchanged. -
translate:
--output <dir>works for a single target language instead of failing withEISDIR, which the multi-target branch already honoured. The destination is now resolved once ahead of every branch, sodeepl translate t.md --to ko --output dir/writesdir/t.ko.mdfor text files, structured files and documents alike;diranddir/behave identically (the check isstatSync().isDirectory()), a trailing slash creates the directory, a converted document is named by--output-format, and--output -and a non-existent path are unaffected. -
translate:
deepl translate <file.json|.yaml>has a size ceiling, so a huge locale file cannot exhaust memory mid-run — the structured route parsed whatever it was given while both sibling routes were already bounded (text at 100 KiB, documents at 30 MB), and the multi-target path parses a fresh copy per target language, up toMULTI_TARGET_CONCURRENCY(5) at once. The ceiling is 10 MiB, matchingHARD_MAX_SYNC_LIMITS.max_file_bytes, and is checked with afs.statbefore the read at the one function both entry points funnel through, so an oversize file is never resident. The message names the file, its size and the limit and points atdeepl sync; a directory run fails only that file. A 25 MB document is still accepted, since it is streamed to the API rather than parsed. -
translate: A directory translation where every file failed exits 1, and a partial failure exits 12, matching
sync— the summary reported✓ Successful: 0 / ✗ Failed: Nand the command still looked like success. A run stopped by one request-level rejection also reports that rejection's own code: 6 for a refusedtarget_lang, 4 for an exhausted quota, 2 for a refused key. -
translate: A rejected language no longer costs one API round trip per batch — a two-letter typo reaches the API now that validation defers unknown codes, and a directory translation asked the same rejected question once per batch (200 files, 200 failing requests). An unsupported
target_lang/source_langis a property of the request, so the remaining batches now fail unsent, while a batch-specific error such as a rate limit still lets the run continue. The abort now also covers the per-file path (.json,.yaml,.html,.srt,.xliff) and not only plain-text batches, files never sent are reported as skipped rather than as failures carrying another batch's error, and the classifier checks the error class before the message so a 5xx quotingtarget_langcannot abort a healthy run. A code the bundled snapshot does not list says so up front, before anything is sent or billed. -
translate: File, directory and
--dry-runruns make the same language checks as text runs, through one entry point rather than eight call sites in five files —--to af --formality morefailed locally for text and reached the network for a file, and--dry-runreported both as runnable along with--to 'not!!a!!lang'.--fromis validated too, and named as such (Invalid source language code: "grman"). Each mode passes only the flags it honours, so a document run still accepts--model-type(stripped after a warning) and a directory run still accepts--glossary; document mode no longer rejects the command over the flags it says it discards; the "deferring to the API" note is said once per code per run; an empty repeatable--glossarylist no longer trips the glossary rejection; and a dry run lowercases--to/--fromas a real run does. -
translate:
--tag-handling-versionis honoured for files and directories, not only for text — the shared option mapping carried--tag-handlingbut not the version, so with v2 now pinned whenever tag handling is on,deepl translate page.html --tag-handling html --tag-handling-version v1silently sent v2. -
translate:
ℹ️ Cache is disabledandℹ️ Cache bypassed for this request (--no-cache)are announced once per run rather than once per API request. -
translate: The error raised for an unexpected API response points at this repository's issue tracker rather than an unrelated third-party account.
-
write/correct:
--format jsonno longer inverts the--checkverdict or corrupts the file--fixwrites.checkTextobtained the improved text by callingimprove(), which renders a JSON document when the caller asked for one, so the diff behind the check compared the original text against that document:write --check 'text' --format jsonexited 8 claiming changes for any input, andwrite file.txt --fix --format jsonoverwrote the file with the JSON document (recoverable only with--backup).--diff --format jsonlikewise labelled a rendered JSON document as the improved text. The improved text is now produced without the presentation formatting, so the verdict, the change count and the bytes written no longer depend on the format the report was asked for;--alternatives,--diff,--outputand--in-placeare byte-identical under both formats. -
write:
deepl writeruns from a published install —diffis imported at the top of the write command but was declared only underdevDependencies, so every command that loaded the module failed withCannot find package 'diff'.--helpdid not surface it, because the module loads lazily. -
write:
--langand--toaccept language codes in any casing and normalize them to the API's form, where they compared against a mixed-case list with an exact match and rejected the lowercase codesdeepl languagesprints andtranslate --toaccepts. -
write: The unsupported-style error links to the published docs URL instead of a
docs/API.mdpath npm users do not have. -
api: A
/v2/translateor/v2/writeresponse body of the wrong shape is refused instead of being carried into output. The bodies were typed by an interface that asserts nothing at runtime and checked only for truthiness and a length match, so{"translations":"notarray"}printed the literal textundefinedinto--output,{"text":12345}printed12345, an object printed{ a: 1, b: [ 2, 3 ] }, andnullcrashed at exit 1. All now exit 5 naming the field and the offending type (Unexpected API response: "translations[0].text" must be a string, got number.), with the bodies requested asunknownand the checks in a newsrc/api/response-shape.ts.write/correcthad the identical hole and are fixed with it. Three distinctions: an absent or nulltranslations/improvementsfield is not a type error and falls through to the callers' own "nothing came back" messages; in a batch every element is validated before any is used; and wrong-typed optional metadata (billed_characters,detected_source_language,model_type_used) is dropped rather than rejecting a translation that has already been billed. -
http: A response the endpoint cuts off mid-body is reported as a network failure (exit 5) —
Network error: the API answered HTTP 200 but its response body did not arrive intact— rather than as invalid input (exit 6), which told the operator to check input that was never the problem. The test is the status rather than the axios code, since a rejection carrying a 2xx response can only have failed while the body was read. 3xx, 4xx and 5xx keep their classification, the replay policy is deliberately unchanged (a 200 means the server may already have billed the request), and the client-side deadline path still restates its own aborts asETIMEDOUT. -
api: A client-side timeout exits 5 (network error) instead of 6 (invalid input), and an HTTP 401 maps to
AuthError(exit 2) instead of falling through to 6 — classification substring-matched the message and missed axios'stimeout of 30000ms exceeded, so CI that retries on 5 and hard-fails on 6 did exactly the wrong thing on a flaky network. Classification now branches onerror.codeand the absence of a response. -
api: Non-idempotent POST requests are no longer re-submitted after a client-side timeout — a batch or document upload that outlived the 30 s timeout was silently re-sent up to three more times, each already accepted and billed server-side, with the worst case being duplicate admin API keys whose secret is returned only once. Automatic retry is restricted to GET, HEAD, PUT and DELETE, and a POST is replayed only on an error that proves the request never reached the server (
ECONNREFUSED,ENOTFOUND,EAI_AGAIN). A 429 is still retried for every method, honouringRetry-After. -
api: A blank or whitespace-only
Retry-Afterheader is treated as absent instead of collapsing 429 backoff into a tight retry loop —Number('')is0, which passed the finite check and, being a real number, kept the jitter-backoff fallback from engaging. An explicitRetry-After: 0is still honoured. -
api: Retries run under an overall time budget rather than only a per-attempt timeout — twice the request timeout by default — so a never-responding server no longer holds a single command for two minutes. Honest
Retry-Afterwaits are not charged against the budget. -
api: The document translation result endpoint is never retried, since the download is effectively single-use and a retry after a timeout could permanently lose an already-billed translation, and document transfers get their own larger timeout.
-
api: The Trace ID quoted in an error belongs to the request that failed rather than the client's last-seen response, so concurrent requests no longer cross-quote each other's. Errors already classified by the API client are no longer re-classified when a client wraps its own error handling — which could turn a validation error into a network error and drop its recovery hint — and the doubled
Network error: Network error:prefix is gone. -
cache: The translation cache keyed on too little and could serve the wrong text.
translationMemoryId,translationMemoryThreshold,--ignore-tags,--splitting-tags,--non-splitting-tags,--outline-detectionand--preserve-formattingwere absent from the key, sodeepl translate "Hello" --to deand the same command with--translation-memory my-tmcollided — the second returned the cached non-TM translation and reportedcached: true— and two runs differing only in--ignore-tagsreturned each other's output.preserve_formattingdoes show up in the text, since it suppresses sentence-boundary punctuation and case correction. Every translation entry cached by an earlier version is retired on first open via a cache schema bump rather than sitting unreachable until its 30-day TTL, so the first translation of any given text after upgrading is refetched. -
cache: The size cap is actually enforced. The total is now read from
SELECT SUM(size)rather than a process-local counter that drifted downward when an overwrite's eviction deleted the key being replaced (after which the cap stopped firing and the DB grew without bound), upward whenget()deleted expired rows without decrementing, and negative when another process's rows were swept (disabling eviction for the process lifetime). Eviction deletes oldest rows in batches until enough space is freed instead of a one-shot estimate from the average row size, an entry larger thanmaxSizeis skipped rather than wiping every other entry first, and the expired-entry sweep's throttle timer is seeded so the sweep runs on a process's first operation — it was seeded to construction time, so it never ran in any process shorter than 60 seconds. RepeatedgetInstance()/close()cycles no longer accumulate signal listeners until Node printsMaxListenersExceededWarning, and the docs now describe eviction as oldest-first, which is what the code has always done. -
cache: Only genuine corruption (
SQLITE_CORRUPT/SQLITE_NOTADB) triggers the rename-aside-and-recreate recovery. The constructor treated every unexpected error as corruption, so transient lock contention (SQLITE_BUSY) renamed a healthy cache aside and broke the other process's open transaction, and a DB written by a newer CLI version was destroyed instead of refused. Lock waits now go throughPRAGMA busy_timeout(5 s) and anything that is not corruption propagates so the run degrades to uncached with a warning. When recovery does run, the WAL/SHM sidecars are copied before the failed handle is closed (SQLite deletes them on close) and named<backup>-wal/<backup>-shmso SQLite can actually recover the preserved data, and rename-aside backups are pruned to the most recent three. -
cache: A cache backend that fails to load is no longer misclassified as database corruption and quarantined — the catch-all renamed a healthy
cache.dbaside and recreated it empty, verified on a 2,646-entry cache that passedintegrity_check. Load failures now leave the database and its-wal/-shmsidecars untouched:deepl translateanddeepl writedegrade to running without a cache (one warning per process, exit 0) whiledeepl cache …subcommands, which cannot run cacheless, fail with an actionable error. -
cache:
deepl cache enable/deepl cache disablepersistcache.enabledto the config file. Both reported success but only flipped an in-memory flag in a process that exited immediately, so the state reverted instantly;deepl cache statslikewise read a process-local flag that always initialized to enabled and now reports the persisted state. -
cli: Subcommand parse errors (unknown subcommand, unknown option, invalid choice, missing argument) exit 6 as documented instead of 1 ("CLI crashed"). Top-level parse errors already did; the mapping is now uniform.
-
cli, cache: Interrupting a command no longer reports success or leaks the sync process lock.
CacheService'sSIGINThandler calledprocess.exit(0), and because the cache singleton is constructed during service setup that handler ran before the sync engine's own — sodeepl syncinterrupted with Ctrl-C exited 0 and left.deepl-sync.lock.pidfilebehind. The cache handler now only closes the database, and termination belongs to the CLI entry point, which defers the exit so every other listener's cleanup runs first (verified as exit 130 with the lock released). Commands that own their shutdown, such assync --watch, opt out and still exit 0. -
cli: The remediation for a missing API key survives
--quiet— it was emitted as a warning, which quiet mode suppresses entirely, leaving onlyError: API key not set, anddocs/API.mdnow describes the actual behaviour. The non-TTY--format tablefallback notice carries the documentedWARNprefix at all six call sites, and shell completions and the did-you-mean suggester no longer offer hidden internal commands — the suggester knows aliases and prefers a prefix match (deepl tr→translate), and--versionis no longer duplicated in the bash and zsh candidate lists. -
cli: The global
--timeout/--max-retriesflags also apply to the API-key validation requests made bydeepl initanddeepl auth set-key, which always used the 30 s default. -
logger: A short credential value no longer corrupts every diagnostic message. The redactor's last step substring-replaced the literal credential with no length floor or token boundary, so
DEEPL_API_KEY=kturned four lines of a single real run into nonsense (Warning: sending your DeepL API [REDACTED]ey to …,/Users/[REDACTED]wey/…) at the moment the user most needs to read them. A literal value is now only replaced when it is at least 8 characters; below that, the auth-header andtoken=/api_key=query-parameter patterns still apply, so nothing shaped like a credential in transit goes unredacted. A token-boundary rule was rejected as it would miss a real key printed before a word character. -
config: Language values are normalized on load, not only by
config set, so a file written or hand-edited with uppercase codes no longer keys two cache entries for one request (DEin config plus--from de).deepl config set defaults.sourceLang DEis accepted where the validator matched a lowercase-only pattern and refused the one casing that is certainly valid; values are lowercased before validation and stored normalized. A code the bundled snapshot does not list is still accepted but now warns at the point of entry rather than failing on every later command, and that note is limited to the write path — shared with the loader it printed on every invocation,deepl --versionincluded. -
config:
config deleteand the config read paths can no longer walk or mutate the prototype chain —__proto__,constructorandprototypesegments are rejected, completing theconfig sethardening. -
init:
deepl initwith stdin at end-of-file exits 6 with the documented non-interactive message instead of starting to prompt and then exiting 1 with a Nodeunsettled top-level awaitwarning. Reached bydocker runwithout-it, CI, and piped invocations: the command checked only--no-input, where the sibling guard inwrite --interactivealso checks whether stdin is a terminal. -
init/write/sync:
@inquirer/promptsis declared as a dependency. It is imported at runtime byinit,write --interactiveandsync initwhile only the unusedinquirerwas declared, so it resolved through npm's hoisting: under a strict layout (pnpm,--install-strategy=nested) those commands failed withERR_MODULE_NOT_FOUND, and under npm the prompt that reads the API key bound to whatever major another dependent happened to hoist. -
package:
import '@deepl/cli'loads instead of throwing. The package is ESM, so Node requires a full specifier for every relative import, but the entry point re-exported'./types'— a directory — which fails withERR_UNSUPPORTED_DIR_IMPORT, making the whole programmatic surface unreachable and the published typings resolve to nothing for anodenextconsumer.deepl --helpnever exercised it, because thebinentry has its own module graph. Both directory specifiers now carry/index.js. -
hooks:
deepl hooks installresolves the hooks directory git actually reads (git rev-parse --git-path hooks), so it honourscore.hooksPath(husky) and works inside linked worktrees and submodules where.gitis a pointer file, instead of reporting success while writing a hook git never runs and crashing with a rawENOTDIR. A repeat install no longer overwrites an existing hook backup (the next free.backupslot is used), the output prints the hook path and the backup path, andfindGitRootno longer loops forever when given a relative start path. -
hooks: Generated git hooks no longer emit a broken install instruction — the
pre-pushtemplate told users to globally install the unpublisheddeepl-cliname, which fails withENOVERSIONS, and now points at@deepl/cli, guarded by a regression test asserting generated hook output never references an unpublished package name. Reported by @maa-xx in #70. -
utils: Atomic file writes preserve the target's existing permissions instead of resetting them to the umask default, so
deepl write --fixon a0600secrets file no longer leaves it world-readable at0644. -
glossary: Deduplicating repeated
--glossaryflags no longer inverts the documented precedence — a repeat kept its first position, so--glossary base --glossary override --glossary baseletoverridewin terms both define although the user putbaselast. A repeat now keeps its last position, which is the one the API applies, and resolved IDs are deduplicated, so naming one glossary twice (or a name plus its own UUID) no longer flips the wire parameter away fromglossary_id, mints a third cache key for an identical request, or spends two of the five slots the API allows. -
glossary:
--glossarywith a source language set only in config no longer fails.TranslationServicemergesdefaults.sourceLang, so the request carriessource_langwhether or not--fromwas typed, but the guard tested the flag alone. The effective source language is now resolved onto the request, which also gives the document path and the glossary preflight the pair they need, and an empty--glossaryselection is no longer treated as a glossary ([]is truthy, so it produced a spurious "Source language (--from) is required"). -
glossary: The language-pair preflight no longer treats two regional variants of one language as interchangeable — both sides were compared on their base language, so a
pt-brdictionary satisfied a request forpt-pt. A dictionary language now matches the requested one exactly or matches the base it reduces to, sode→enstill covers--to en-usand a dictionary namingpt-brstill matches--to pt-br. -
glossary: Six smaller defects around the multi-glossary work.
syncresolved its glossary without the language pair, so a non-covering glossary failed once per file rather than at startup, unlike the sibling translation-memory resolution;translate --dry-runandwatch --dry-runreported as runnable a glossary command the real run rejects for a missing--from; the document path never ran the extended-tier constraint check, newly reachable now that documents accept glossaries, so it uploaded the file and let the API refuse it;glossary infoprinted raw dictionary languages beneath a normalized summary, so one glossary could showEN → DEunderSource language: en; and a coverage error listed every dictionary of a multilingual glossary on a single line. -
glossary:
deepl glossary add-entry/update-entryreject terms containing a tab, carriage return or newline instead of shifting every following column of the uploaded dictionary, and glossary import picks the TSV or CSV dialect once per file so a quoted CSV field containing a tab is not split into garbage columns. A term named after anObject.prototypemember is no longer dropped or falsely flagged as a duplicate —tsvToEntriesused a plain-object accumulator, sotoStringtriggered a spurious "Duplicate source" warning and a__proto__term was swallowed by the prototype setter — while genuine duplicate detection is unchanged. Scoped commands (show,entries,delete) also no longer emit an unrelated org glossary's "empty dictionaries" warning during name resolution. -
sync: Auto-glossary sync (
translation.glossary: auto) skips terms whose source or translation is empty or contains a tab, carriage return or newline, which were uploaded as corrupted or outright wrong glossary entries that DeepL then applied to live translations. An unchanged dictionary is no longer re-uploaded on every run (entries were compared against a lossy TSV round trip that could never compare equal), a glossary failure for one locale no longer ends glossary sync for the remaining locales — the warning names the locale, glossary and cause — and term extraction, which is keyed by untrusted source strings, is prototype-safe. -
sync: The startup glossary coverage check no longer requires the top-level glossary to cover locales that are configured with their own
locale_overrides.<locale>.glossary, which aborted a documented configuration before any file was touched. Per-locale glossary and translation-memory overrides are now resolved and checked once at startup against their own locale rather than re-resolved per file, so a bad reference fails before anything is translated;locale_overrides.<locale>.glossarypreviously skipped the coverage preflight entirely.sync --dry-runresolves and checks the same references a real run does — it already needs an API key to reach that point — while still sending nothing to/v2/translate. -
voice:
--glossaryis resolved after the command's local checks rather than before them, sovoice a.ogg --to bogus --glossary my-termsno longer spends a glossary-list round trip to then fail locally, and resolution passes the requested language pair so a non-covering glossary fails locally instead of at the API. The pair checked is the canonical one voice sends, so--from EN --to zh-hansis checked asen→zh-HANS; without--fromthere is no pair and the API still judges it. -
voice: A session that ends with the audio transcribed but no translation for a requested
--tolanguage now fails with exit 9 and names the languages, instead of printing an empty translation line and exiting 0. The failure carries the transcripts that did arrive, printed to stderr before exiting, since the audio is transcribed and billed before the missing translation is noticed. Target updates are matched case-insensitively, because the requested spellings includezh-HANSanden-GBand a differently canonicalized echo used to be dropped silently and then reported as missing. Audio containing no speech transcribes and translates to nothing, which is legitimate and still exits 0; whitespace-only and never-concluded translations count as missing. -
voice:
deepl voicereconnects after a transport failure. The socketerrorhandler marked the stream ended before thecloseevent that always follows, so the reconnect path (up to 3 attempts,--reconnecton by default) was unreachable for a real network drop and only a clean remote close ever reconnected. A reconnect that exhausts its attempts now closes the audio input instead of leaving the stream generator awaiting forever. -
voice:
--quietno longer discards the salvaged transcripts of a failed session — the partial result went through the warning channel, which quiet mode suppresses, while the live display was erased regardless, so audio that had been transcribed and billed left nothing on screen. It goes through the error channel now, the display is cleared only once there is something to print in its place, and--format jsonis honoured on that path. The live display also keyed target rows by the requested spelling, so with--to zh-HANSazh-Hansecho left the row blank for the whole session; it is matched case-insensitively. -
voice: Regional target and source codes may be spelled in any casing —
--to en-gband--to zh-hansexited 6 while--to en-GBworked, even thoughdeepl languagesprints the lowercase form. Codes are matched case-insensitively and canonicalized to what the Voice API expects. -
languages: Four target languages the API accepts were unusable:
de-CH(Swiss German),de-DE,fr-CA(Canadian French) andfr-FRare returned byGET /v3/languagesand accepted by the translate endpoint, but the bundled list did not contain them, sodeepl translate --to de-CHfailed locally withInvalid target language code— and thedeepl languagesthe error suggested did not list them either. Swiss German and Canadian French had no workaround. The list now contains all 125 languages the API serves (32 core, 11 regional, 82 extended). -
languages:
deepl languages --targetmarks Portuguese (pt) with[F]. Formality support is read fromfeatures.formalityonGET /v3/languagesrather than a static table: the v3 migration assumed v3 stopped reporting formality, but v2'ssupports_formalityboolean had only become the presence of aformalitykey in the per-language features matrix, so the CLI was answering from an 11-entry snapshot of the final v2 response. That snapshot is gone; the[F]set is otherwise identical and the registry'scategorytiers are unaffected. -
languages:
--featuresno longer claims knowledge it does not have. Snapshot entries the API response omitted were every one rendered as the positive claimnone— with a response covering a handful of languages, over a hundred were reported as supporting nothing — and those rows also made every feature look non-uniform, so features shared by all described languages became columns of repeated values instead of the footer note the flag was built around. A feature reported without astatusno longer renders as the literalundefined,supportsFormalityis no longer assertedfalsefor a language the response never mentioned (which turned on the[F]legend with no[F]to explain), only a language credited with nothing at all readsnone, the Formality column is no longer disabled without being replaced, and the?cell for an undescribed language has a legend.deepl languagesalso makes oneGET /v3/languagesrequest instead of two identical ones. -
languages: A language
GET /v3/languagesdescribes with no feature matrix is no longer filed as extended — the tier was derived from the absence of glossary support, and an absent matrix is silence rather than a denial. Since the extended tier is what refuses formality and glossary before a request is sent, such a language is now tiered by source usability and the API keeps the judgement; an empty matrix is still evidence and still means extended. An absentusable_as_source/usable_as_targetflag is likewise read as "usable" in the language and glossary-pair listings, matching the registry — the two disagreed, so a language whose flag was absent was dropped fromdeepl languageswhile the generator recorded it as core. -
languages:
deepl languages --format jsonwith no API key falls back to the bundled snapshot instead of answering{"source":[],"target":[]}while the text and table formats printed all 125, so listing languages works offline in every format. -
languages, voice: Strings the API supplies are sanitized before they reach the terminal —
deepl languagesprinted names, feature keys and statuses verbatim andvoicedid the same with transcript text and language labels, so a hostile or intercepted endpoint could move the cursor, clear the screen, or hide text behind a bidi override. Both now go through the samesanitizeForTerminalthe glossary and style-rule listings used, replacing control and zero-width characters with?. It matters most forvoice, whose live display clears a fixed number of lines.voice --format jsonkeeps the text byte for byte. -
usage: Voice usage no longer reports the API key's own consumption as the account total. Duration-billed products fell back to
apiKeyUnitCountfor the account figure, because live responses omitunit_countfor them, so both columns showed the same number; the account-wideaccount_unit_countthe response does carry was neither typed nor parsed and now is, and where no account-wide figure exists the row reads(API key)rather than inventing a total. -
usage: Text and table output no longer report duration-billed products as zero characters — duration billing units are recognized, rendered in h/m/s as documented, and product names print in the documented snake_case (
--format jsonwas already correct). A character count is read only formillisecondsbilling, where it carries the duration, so a duration-billed product can no longer render a character count as hours; anything non-finite is treated as absent rather than reported as a genuine zero. -
admin: An entitlement failure (a valid key without admin scope) no longer suggests re-running
deepl init/auth set-key; the suggestion explains that the admin API requires an administrator API key. Exit code and classification are unchanged. -
scripts:
npm run generate:languagesrefuses to write a snapshot that would break the CLI: an empty write list would collapse theWriteLanguageunion tonever, rejecting every--langwhile naming no valid option, and a features matrix that stopped reportingglossarywould retier all 125 languages as extended and make--formalityand--glossaryunusable everywhere.--checkcompares whole blocks rather than quoted codes, so a renamed display name is reported as real drift instead of "formatting only", and the generated file is in.prettierignoresonpm run formatcannot make that check fail permanently. Both resources are fetched together and their failures reported together, so a key that cannot readresource=writeno longer blocks regenerating the translation list. A thrown fetch or unparseable error body is reported as anerror:line at exit 1 rather than an unhandled rejection with a stack trace. -
scripts: The language generator no longer writes unescaped API response fields into TypeScript.
langwas interpolated into a single-quoted literal with no escaping andnameescaped only quotes, so a response field containing' }] as const;— or merely ending in a backslash — could append arbitrary code tosrc/data/language-entries.ts, which the next build compiles and the test suite imports. Codes are now validated against the language-tag pattern, display names against a conservative character set, categories against the three tiers, and every value is quoted with escaping, with validation running before grouping. The generator's main guard also resolvesargv[1]throughrealpathSync, because Node reports the ESM entry by its real path — under a symlinked checkout both npm scripts exited 0 without doing anything, including the release step that keeps the Write list current. -
perf:
sync auditregistration no longer loads fast-glob on every CLI invocation (lazy import, matching its sibling subcommands). -
docs: Documentation inaccuracies found in the pre-2.0 audit.
docs/API.mdandREADME.mdsaid the last--glossarywins a conflicting term, contradicting the flag's own help text and the verified behaviour — which mapping wins is the API's choice and does not follow flag order.docs/API.md's command-group table omittedcorrectwhile claiming to matchdeepl --help, its environment-variable reference omittedNO_PROXY, and the README's table of contents omitted its Spelling and Grammar Correction section. Three further passages described behaviour that no longer exists: thevoice --glossaryrow had picked uptranslate's repeatable/--fromsemantics (voice takes one glossary and requires neither), thewritereference still said an unknown code is rejected locally, and theusagereference still documented the removed Speech-to-Text section along with output showing the duplicated API-key figure.docs/TROUBLESHOOTING.md's "Translation cache backend failed to load" entry attributed the failure to running Node < 24, which the startup version check rejects earlier with its own message, so the stated cause was unreachable; that entry now describes the reachable case (a v24+ runtime with no usablenode:sqlite) and the version error is documented under exit code 6, where it previously appeared nowhere. TheNODE_MODULE_VERSION/npm rebuild better-sqlite3entry is gone with the dependency. -
docs: Further corrections. The README's translate examples no longer show a
Translation (XX):label the CLI never emits, and--model-type(no CLI default),--configprecedence (replaces the config file only; the cache path is unaffected), unknown-command anddeepl detectsample output, and the nonexistent 10 MB PDF cap (the document limit is 30 MB uniformly) are corrected; the README now coversdeepl sync,deepl tmand all ninestyle-rulessubcommands, with dead in-page anchors repaired anddocs/SYNC.mdlisted under Documentation.TROUBLESHOOTING.md's exit-code table gains codes 10–12 (SyncDrift, SyncConflict, PartialFailure) and its environment-variable table gainsTMS_API_KEY,TMS_TOKEN,FORCE_COLORandTERM; sync JSON-contract stability promises are rescoped from "1.x" to "within a major version"; the GitHub Actions recipes indocs/SYNC.mdpin Node 24;CONTRIBUTING.mdno longer cites Zod (validation is commanderOption.choices()plus hand-written validators); andexamples/README.mdno longer references a nonexistentsample-files/directory. -
examples:
examples/03-batch-processing.shno longer hides five--output <dir>failures behind2>/dev/null,|| trueand a(cached or completed)message that reported a cache hit for a call that had errored — sonpm run examplesreported 37/37 passed while this was broken. Section 8's cache comparison now times calls that actually succeed. -
examples: The GitHub Actions and GitLab CI recipes in
examples/21-cicd-integration.shandexamples/23-sync-ci.shtarget Node 24 and set up Node explicitly. They pinned Node 20, or omittedsetup-nodeentirely, so a copied recipe exited 6 on the Node floor.examples/39-advanced-translate.shalso no longer calls--tag-handling-version v1the default; v2 is.
- sync:
deepl sync validatewas inert for PO and XLIFF buckets and reported the opposite of the truth. The gate compares each target value against its source, but for a bilingual format it read the msgid /<source>on both sides, so it was comparing the source against itself: amsgstrdropping a placeholder its msgid carries exited 0 reporting1 warning(s), and that warning wasTranslation is identical to source text, a false positive raised against every entry in every PO and XLIFF bucket, correct translations included. It now exits 8 withERROR es/Hello {name}: Missing placeholders in translation: {name}. Anything using this as a CI gate on a PO or XLIFF project was gating on nothing. - sync:
--forceis refused (exit 6) when there is no terminal to confirm it on, instead of treating "nobody can answer" as yes. The guard threw forCI=trueand prompted on a TTY with no third branch, so in a git hook, cron job,maketarget, container entrypoint, Jenkins agent or plaindeepl sync --force < /dev/nullthe prompt was skipped and the run proceeded — inverting the fail-closed convention the seven other destructive sites inherit, for the single most destructive operation the CLI has, replacing reviewed translations with machine output with no backup surviving.--no-inputwas ignored on the same path, though it documents itself as aborting instead of prompting. Both now exit 6 with a message naming--yesas the only way to run--forceunattended; an interactive decline is unchanged at exit 0 and now printsAborted.. The gate is a singlecanPrompt()inutils/confirm.tsthatconfirm()itself uses, so the two cannot drift apart. - sync: A
tms.servervalue in the checkout can no longer redirect the operator's environment-heldTMS_API_KEY/TMS_TOKENto a host of its choosing —.deepl-sync.yamlpicked the destination and the only guard was scheme, not identity, so a hostile checkout plusdeepl sync pushdelivered the credential and every translated string to a listener of its choice at exit 0. A hybrid allowlist plus trust-on-first-use now gatescreateTmsClient, so bothpushandpullare covered by one choke point: the hostname must appear in a new user-leveltms.allowedServerslist, or be approved once at a prompt that names the host and states that the credential and every translated string would go to it. An accepted answer is recorded in user config, never in the repository, so it survives a fresh clone and does not travel with the repo. Under--no-inputor on a non-TTY the run fails closed at exit 7, naming the host and the exactdeepl config set tms.allowedServers <hosts>command with the already-approved hosts preserved. Matching is exact and case-insensitive on the parsed hostname, ignoring scheme, port and path, with no wildcards, and entries carrying a scheme, port, path or*are rejected atdeepl config setrather than stored as approval that could never match. Loopback is not exempt; a credential inlined astms.api_key/tms.tokenis not gated, since it belongs to the same file that chose the destination. Both commands now also print the resolved destination origin on success, in text and in JSON ("server"), so a redirected destination is visible in logs even for an already-approved host. - sync:
deepl sync push/pullno longer send the TMS credential and translated strings to a host the destination-trust gate never approved. Atms.serverwhose path begins with//—https://approved.example.com//evil.example.com, orhttp://localhost//169.254.169.254— parses with the approved hostname, which is what the gate and the HTTPS/localhost checks key off, butbuildUrl's relative resolution then sent the request to the other origin. The resolved request origin is now pinned to the approved one, with aConfigErrornaming the redirected origin otherwise; a legitimate base path (https://tms.example.com/tms) is unaffected. The threat is a maliciously contributed.deepl-sync.yamlin a repo whose maintainer runsdeepl sync push. CWE-918. - cli: Pointing the CLI at a non-DeepL endpoint is now announced, so the API key no longer travels to an unexpected host in silence.
isStandardDeepLUrl()existed solely to detect this and was consumed by nothing; aconfig.jsonholdingapi.baseUrlredirected every request with nothing on any channel to say so, and-vprinted the method and path but never the host. When the resolved endpoint is neitherapi.deepl.comnorapi-free.deepl.com, an unconditional warning names the origin the key is going to and where the redirect came from —set by --api-url, orset by api.baseUrl in /path/to/config.json, the real resolved path, which is what makes a substituted config visible. Only the origin is rendered, terminal-sanitized, since the path and query are chosen by whatever did the redirecting. It is not gated behind--verbose(though--quietsuppresses it, like every other warning), goes to stderr, fires once per run rather than once per client, and exempts neither loopback nor regional endpoints such asapi-jp.deepl.com. The verbose request line now names the resolved origin, andConfigServicegained aconfigFilePathaccessor. - api: An endpoint that keeps sending is now bounded in both bytes and wall-clock time. The shared axios instance never set
maxContentLengthormaxBodyLength, which default to unbounded, so a response body was buffered until the process died — and--timeoutdid not save the run, because axios delivers it to the socket as an inactivity timeout that every arriving chunk reset. A stub streaming 1 MiB chunks forever reached 8572 MB RSS and was still running, un-aborted, four times past the configured deadline; a stub trickling one byte per second stayed under any byte cap indefinitely while pinning the process. Two changes: a finite 32 MiB response cap and 128 MiB body cap on the shared instance, and anAbortControllerdeadline armed per attempt inside the retry loop, which is wall-clock and therefore not resettable by the peer. The document download raises its response cap to 128 MiB, since a converted result can exceed its source and that endpoint is deliberately never retried — rejecting there would destroy an already-billed translation — while status polling and the other nine clients keep the tighter default. Both bounds surface asNetworkError(exit 5), the size cap naming the limit it hit, and a size-cap rejection is not retried, since the verdict is deterministic. - api: A batch translation response that returns the submitted texts themselves, rearranged, is refused instead of written out.
POST /v2/translatecorrelates a translation to its request item by position and by nothing else, and the only check was that the counts matched — so an endpoint returning the request's own texts rotated by one wrote each translation under the wrong i18n key at exit 0, hiding a destructive action behind a cancel label, which the placeholder validator cannot see because plain UI strings carry no placeholders to compare. Such a response now exits 5 with nothing written. The check requires both a multiset match against the submitted texts and at least one moved position, so it cannot fire on a legitimate identity translation or on one item whose translation equals another item's source text. It sits in the one client method every batch caller shares —translateon all 11 structured formats, plain-text batches, andsync. An endpoint returning plausible translations in the wrong order remains undetectable, since the protocol carries no per-item identity. - cli, sync: Text the CLI did not author can no longer drive the terminal that renders it. Translation text went from the API to stdout raw and i18n keys went from a checkout to stdout raw, so both could carry terminal control sequences — and the key sink needs no API key and no network: an evil checkout plus
deepl sync validateemitted an OSC 52 clipboard write and a CSI 2J screen erase verbatim, andhooks installputs that command in the pre-commit hook, so it fires on every commit. Two layers close it. Untrusted values interpolated into report lines are sanitized at the call site, unconditionally — the locale, key and message insync validate, and the choice labels inwrite/correct --interactive, where the value handed back to the caller stays raw so only the display is affected. And the logger neutralizes control sequences centrally, covering all 111Logger.outputcall sites and every stderr sink by construction. stderr is sanitized whether or not it is a TTY, because CI log viewers interpret ANSI; stdout is sanitized only when it is a terminal, because redirected stdout is data anddeepl translate ... > out.txtmust reproduce the API's bytes exactly — the same rulelsandgitapply. Colour (SGR) sequences survive, since chalk-formatted strings arrive already rendered, while OSC title and clipboard writes, CSI erase and cursor moves, and the status queries whose reply is typed back on the shell's stdin are all neutralized. The stream filter is a separate, narrower function fromsanitizeForTerminal, which replaces newlines, tabs and U+200B-U+200F and would therefore have corrupted legitimate content in Persian, Arabic, Devanagari and emoji sequences. - formats: No writer emits a raw C0 control byte into a repo file. Six of the nine writers escaped only their own quoting characters plus
\n/\r/\t, so any other control byte in a translation was written through untouched, putting live terminal-control sequences into source-controlled files wheregit diff,cat,lessand CI log viewers all render them. No API call is needed to trigger it: a contributor commits a validlocales/app.de.tomlholdinggreeting = "Hola\u001B[2J"— TOML basic strings legally carry\uXXXXand the parser decodes it to a raw ESC — the key is thencurrent, so it is never translated and never validated, and the writer put the byte back out raw. That is quieter and worse than the file simply failing later:deepl syncexits 0 and says nothing, because the target-file read treats a parse failure as "no existing translations", anddeepl sync statusreports100% (0 missing, 0 outdated)for a file it cannot parse. Android XML and XLIFF are worse still, since every C0 byte except tab, LF and CR is outside XML 1.0'sCharproduction, so there is no escape and no numeric character reference for it and expat,aapt2and every conforming CAT tool reject the written file. One shared rule now lives insrc/formats/util/control-chars.tsand each writer applies it the way its own format allows: TOML escapes as\uXXXX(forcing the double-quoted form where a literal string could not escape, plus U+007F),.propertiesextends its existing\uXXXXrule below U+0020, iOS Strings emits\UXXXX, which its own reader already decodes, PO gains the missing\rescape and refuses the rest with aValidationErrornaming the entry, and Android XML and XLIFF refuse with aValidationErrornaming the resource or trans-unit. Each message names the codepoint (U+001B) rather than quoting a byte that prints as nothing. - sync: A source catalog can no longer smuggle one string into another string's key. Five parsers encode hierarchical key identity in-band with no escaping — PO joins
msgctxtandmsgidwith U+0004, YAML joins path segments with U+0000, and JSON, Laravel PHP and Android XML join with.— so a key component holding the separator resolved to the same key as an unrelated entry, and the two control bytes print as nothing, so the source diff showed an ordinary string. A PO catalog carrying U+0004 inside onemsgidextracted the same key as a legitimatemsgctxt/msgidpair, and reconstruct wrote the smuggled entry's translation into that pair'smsgstr: the attacker picks the source text, DeepL translates it, and the result lands in the victim key's slot in every locale. The stronger case needs no collision at all — one suchmsgidin a catalog with nomsgctxtanywhere forged a context-qualified entry the source never had, in every target.po, which a duplicate-key check alone would have missed. For the.-joining formats the damage is a lost or corrupt file rather than a forgery: a flat"a.b"beside a nesteda: { b: ... }rewrote both slots to one translation, an Android<string name="items.0">colliding with<string-array name="items">was deleted from the output, and the Laravel case emitted PHP the CLI's own parser then refuses. PO and YAML now reject the reserved byte at both key-construction sites (extract, and the target-file read inside reconstruct), quoting it back escaped as\u0004/\u0000rather than echoing a byte that prints as nothing; JSON, Laravel PHP and Android XML assert their extract output has distinct keys, which for those three is exactly equivalent to detecting a separator collision.syncskips the one file with a warning naming the colliding key and finishes the rest of the run at exit 0, following the existing per-file convention, since one hostile file must not discard the lockfile for work already translated and billed.sync pullreading a target file whose keys collide leaves it untouched and records akey_collisionskip rather than falling through to the source template, which would have rebuilt the locale down to the single key the export carried..propertiesand XLIFF are deliberately excluded, since a literally repeated key is legal in both. - sync: An i18n key named
__proto__is recorded in the lockfile instead of vanishing from it, so it is no longer re-translated and re-billed on every run, forever — every write into the entry maps was a plain assignment, so for that one name it reached the prototype setter and the run reported3/3 keyswhile the lockfile came back with two entries andtotal_keys: 2, with no run ever converging. The same shape applied to a source path named__proto__, which fell out ofentrieswholesale and took every key in that file with it. The read side needed the same treatment for a different reason: plain indexing hands back inherited members, so keys namedconstructor,toString,valueOforhasOwnPropertyread as an existing lock entry missing every field it should have, andcomputeDiffclassified all four as stale rather than new on a first sync. Three shared accessors (setOwnMember/getOwnMember/ensureFileEntries) are applied at every access site across six files, including two insync-status.tsandsync-locale-translator.ts, and the key-sorting JSON replacer is fixed the same way. - sync: A lockfile is checked member by member before sync uses it, instead of being version-checked and then cast. Six shapes from a hostile or merge-mangled repository were reproduced by execution: a missing
statscrashed at exit 1 after the endpoint had been hit and the target file written, leaving the attacker's stub as the lockfile so the next run re-billed everything; an entry with notranslationscontainer crashed, and took the read-onlysync statusdown with it; a per-file map that is a string crashed;entriesas an array was accepted silently at exit 0, with the run's results written onto array properties and then discarded, so the lockfile recorded"entries": []besidetotal_keys: 2and every later run re-billed the project in silence, forever; and a null translation and a stringstatseach crashed one level deeper. Every one of them now exits 0 with a well-formed lockfile. Malformed members are dropped individually, not by discarding the file — resetting a whole lockfile over one bad member would hand whoever wrote it a full re-translation of the project — with the count reported at WARN level and the original copied to.deepl-sync.lock.bak-v1-malformed-<timestamp>;entriesthat is not a map at all has nothing to salvage and takes the existing full-sync path with its own-entries-not-a-maptag.statsis no longer trusted at all: it is derived fromentriesand recomputed on read and on every write, so its counts can no longer disagree with the entries they describe. The repairedentriesis rebuilt withsetOwnMember, so a source path or key named__proto__is carried across as an own property. - sync:
sync pullno longer recordsreview_status: human_reviewedfor content it never verified was reviewed. Every key a pull applied was stampedhuman_reviewedin.deepl-sync.lockunconditionally, although the documented export contract is a flat map with no per-entry review flag — so a TMS that exports machine-translation drafts had them recorded in a committed file as human-reviewed, and where thetms.serverin a checkout is not one the operator chose, the endpoint controls the strings and the review label attached to them. Pulled entries now recordstatus: translatedwith noreview_status, which is the type's way of saying "unknown";human_reviewedis still honoured when a person or another tool writes it, and--flag-for-reviewstill writesmachine_translated. - translate: A document upload response can no longer choose where this client sends its own follow-up requests. The
document_idfromPOST /v2/documentwas interpolated into the status and result paths with no encoding and no format check, and the endpoint supplying it is redirectable via--api-url,api.baseUrlor a proxy: against a stub answering a traversal-shapeddocument_id, the client's next two request lines arrived on a completely different route, and the run finished at exit 0 reporting success after writing the stub's JSON body into the output file as if it were a translated PDF.document_idis now checked against[A-Za-z0-9_-]+at both interpolation sites, so a redirected first response stops the workflow at exit 5 (NetworkError, since nothing the user typed is wrong) with the ID quoted back, nothing further sent and no output file written; a response with nodocument_idat all, which was reaching the wire as the literal path/v2/document/undefined, is refused by the same check. The polling loop re-sends the original handle rather than the ID echoed by each status response, so a clean first response cannot be followed by a poisoned second one. This was the last unguarded interpolation of an untrusted value into a URL path in the API layer. - logger: The credential redactor no longer has two holes that between them defeated its own documented invariant. Hole one: any object that was not a plain object, an array or an
Errorwas returned live — anErrorwhoseconfig.headersis anAxiosHeadersinstance printedAuthorization: 'DeepL-Auth-Key SUPER-SECRET-KEY-FROM-CONFIG'verbatim throughutil.inspect, while the sibling plain-object field on the same error was redacted correctly, andMap/Setleaked worse still, since their contents are not own properties at all. Every object is now rebuilt property by property on its own prototype, soutil.inspectstill names the class while never receiving the original instance, andMap/Setare rebuilt with their keys and members mapped;Date,RegExp,ArrayBufferand its views pass through deliberately, since their payload is not in string-keyed own properties and none of them renders a credential as readable text. Properties aredefineProperty'd, so an own key named__proto__lands on the copy instead of reaching the prototype setter. Hole two: the literal-value backstop read onlyprocess.env, while a config-file key wins precedence over it — so with a key inconfig.jsonand a different one inDEEPL_API_KEY, the value actually on the wire was precisely the one the redactor could not see, reproduced against a loopback stub that echoed it back in an error body. A newLogger.registerSecret()closes it, called from theHttpClientconstructor — the one place that sees whichever key won precedence, because it is where the key is attached to every request — and from theTmsClientconstructor, which covers atms.api_keyortms.tokeninlined in.deepl-sync.yaml. Redaction also recurses through objects, arrays andErrorvalues with cycle protection rather than applying only to strings. - hooks:
deepl hooks listno longer reports attacker-authored content as an installed DeepL hook, and the integrity check it already had is now on a path a user can reach.isDeepLHook()fell back to a bare substring match, so a file mentioning the marker anywhere, on any line, in any context passed as a hook this CLI installed; andverifyIntegrity()— which parses the# DeepL CLI Hook v1 [sha256:...]marker, rehashes the body and compares — was referenced by nothing but its own definition and its unit tests, so the recorded hash was never checked outside the test suite. A repository shipping a tracked.githooks/pre-commitwith a forged marker plus a payload, wired up withgit config core.hooksPath .githooks(the husky pattern), was reported✓ pre-commit installed, with--format jsonsayingtrue.listnow reports one of four states per hook —installed(versioned marker present and the body hashes to what it records),modified,unverified(legacy pre-1.0 marker, no hash to check),not-installed— so the forged hook reads! pre-commit installed, content does not match its recorded hashand the quoted-marker file readsnot installed. The legacy marker is now anchored to a whole line the way the versioned one always was. The output states plainly that the hash detects a change made after the marker was written and cannot establish authorship, because it is unkeyed — anyone who can write the hook can compute a matching marker — somodifiedrenders in yellow and gives both readings rather than accusing, since the README invites customizing an installed hook. TighteningisDeepLHookalso makesuninstallrefuse to delete a file whose marker is only quoted, and makesinstallback such a file up instead of overwriting it. - hooks:
deepl hooks installno longer writes an executable outside the repository because the repository told it to.resolveHooksDirasksgit rev-parse --git-path hooks, which faithfully honourscore.hooksPathincluding an absolute path anywhere on the filesystem, and the install then did an unconditional write pluschmod 0755with no containment check — andcore.hooksPathis repository-local git config, so it travels with a checkout rather than coming from the person running the command:git config core.hooksPath /outside/dirfollowed bydeepl hooks install pre-commitprinted success, exited 0, and left an0755script there with nothing on any channel to say the write had left the project. An install whose hooks directory falls outside the working tree now names the configured value and the resolved directory and asks first; declining, or having no terminal to ask on, exits 6 and writes nothing, while-y, --yesaccepts it and still prints the notice to stderr so a scripted install records where the executable went. The refusal lives inGitHooksService.install, which needs an explicitallowExternalto proceed, so a caller that forgets to prompt cannot skip the gate. Only the repository-local setting is consulted — a globalcore.hooksPathis the user's own machine-wide choice — which is also what keeps linked worktrees and submodules quiet; containment is checked through symlinks; anduninstallis left alone, since it already refuses to remove anything that is not a DeepL hook. The predicate is nowisWithinDirectoryinsrc/utils/paths.ts, shared withassertPathWithinRoot. - config: The
0600mode on the file holding the plaintext API key is enforced on every load, not just asserted at creation.config.jsonis created0600and its directory0700, and neither was ever checked again, so a file restored from a tar or dotfiles backup, copied byrsync, or written by hand kept whatever mode it arrived with, forever, whileload()read it with nostat, no repair and no warning. A group- or world-reachable config file is now tightened back to0600on load and the run says what it found — naming the mode, that other users on the machine can read it, that the key may already have been read, anddeepl auth set-keyto rotate it — and the warning is self-extinguishing, since the next run finds0600. The directory is deliberately reported rather than repaired: its dirname is not always a directory the CLI owns, anddeepl -c ~/deepl.jsonwould have chmod'd the user's home directory to0700, so it names the mode and thechmod 700that would close it and changes nothing. Only the write bits are reported, since a merely traversable0755directory does not let anyone replace the file inside it; sticky directories are exempt, because mode1777is exactly the arrangement that makes a shared temp directory safe; and the cache directory gets the same report, whilecache.dbneeded nothing, being0600on every open. Bothmkdirsites are now unconditional recursive calls, closing the window where a directory could appear between anexistsSyncand a create. Nothing refuses to run over a permission: the mode of the file is not a reason to reject the settings in it. - config:
ConfigService.save()writes through an unpredictably named temp file created with an exclusive flag instead of a fixedconfig.json.tmp, where a planted symlink redirected the config — which holds the API key in plaintext — to a path of the planter's choosing, and the subsequent rename leftconfig.jsonas that symlink so every later write followed it too. The mode is applied withchmodafter creation, which the umask cannot widen. - config:
deepl config setrejects__proto__,constructorandprototypepath segments and resolves keys withObject.hasOwn, so crafted paths cannot polluteObject.prototype. - sync: A sync target path can no longer begin with
-, closing the defense-in-depth half of thegit add/git commitoption-injection fix. The argv side was already fixed, but.deepl-sync.yamlcould still name a target that looks like an option —target_path_pattern: --pathspec-from-file={locale}satisfied all four existing checks (a string, contains{locale}, no.., no.git/.githubsegment) — and there were three routes to a dash-leading target: the literal pattern, a{basename}taken from a source file whose own name begins with-, and the default locale-substitution branch running over a dash-leading source directory, which involves no pattern at all. Two tiers now apply, mirroring howFORBIDDEN_TARGET_SEGMENTSis enforced twice: a literal pattern beginning with-fails at config load withConfigError(exit 7), and every pathresolveTargetPathrenders is checked again withValidationError(exit 6), which is the only tier the other two routes pass through. Only the first segment is checked, since the rendered path is one argv entry and a dash later in it is never option-like, sores/values-{locale}/strings.xml,locales/zh-Hans.jsonandlocales/-legacy/{locale}.jsonall still resolve. - sync, watch: Auto-commit passes staged paths after a
--separator and commits with--only, so a translation target path can no longer be read by git as an option and the commit can no longer carry anything else the user had staged.execFileprevents shell injection but not git's own option parsing, so atarget_path_patternrendering a leading dash reachedgit addas a flag and staged files of the pattern author's choosing, defeating the auto-commit preflight that exists to bound the staged set;git committhen ran with no pathspec and committed the whole index, so a separately staged.env.localor unfinished work landed in achore(i18n)commit whose message described only the translation. Bothsync --auto-commitandwatch --auto-commitwere affected —syncwas partly shielded by its unrelated-modifications preflight,watchwas not. - cache: The resolved API base URL is now part of every translation,
writeandcorrectcache key. Onecache.dbis shared by every endpoint a config directory has ever talked to, and the key hashed 20 request parameters but not who was going to answer them — so a singledeepl translate "hello" --to DE --api-url http://127.0.0.1:18111served that endpoint's answer back forapi.deepl.comfor the full 30-day TTL, with no network reachable at all. Custom endpoints are a supported feature (proxies, regional endpoints), so this needed nothing the tool discourages: pointing the CLI at a local stub once was enough to make its output the cached truth everywhere. The endpoint is derived from the same expression the HTTP transport uses (resolveClientBaseUrl) rather than a second copy that could drift, and the free and Pro endpoints are keyed apart.CACHE_SCHEMA_VERSIONmoves to 3, so opening an existing DB drops itstranslation:,write:andcorrect:rows — every one of those keys changed, and retiring them also clears any entry a custom endpoint already poisoned. Other namespaces are untouched, and cache writes are deliberately still allowed for non-standard endpoints, since with the endpoint in the key there is nothing left to cross-contaminate. - sync: The startup stale-backup sweep no longer writes anything, and no longer takes the whole project as its scope. It restored any zero-length file from a
<file>.deepl.baksibling, with no check that the sibling was a translation target, was matched by a bucket glob, was tracked, or came from this tool — so a hostile checkout shipping an empty tracked file plus a.deepl.bakalongside it (any clone older than five minutes qualifies, since checkout stamps mtimes at clone time) got bytes of its choosing written into that file during sync startup, before any translation, at exit 0, with a single warning as the only trace and the.bakunlinked immediately after. Because every target write goes throughatomicWriteFile, which renames a fully written temp file into place, a crash cannot leave a zero-length target: the restore branch had no legitimate trigger left and is removed, so the sweep only ever unlinks. Separately, a bucket glob beginning with a wildcard (**/en.json,*.json) has no literal prefix, and the old fallback handed the sweep the entire project root to walk recursively, defeating the scoping the function exists to provide. Such a glob now contributes no sweep root, which means a bucket configured that way gets no stale-backup cleanup and may accumulate inert.deepl.bakfiles; run with--verboseto see when this is skipped. - sync: No sync path may resolve into
.git/or.github/.FORBIDDEN_TARGET_SEGMENTSwas checked against a literaltarget_path_pattern, so a bucket that simply omitted the pattern reached the default locale-substitution path unguarded:buckets.yaml.include: ['.github/workflows/en.yml']madedeepl syncwrite.github/workflows/de.ymlwhoserun:body was whatever the translation endpoint returned — CI workflow code under the influence of a hostile checkout or a hostile endpoint, at exit 0 — and the containment check on the project root accepted it, because the path never leaves the root. The check now sits on the resolved path insideassertPathWithinRoot, the one boundary every read and write in the pipeline passes through, so the substitution branch and the multi-locale branch (which writes back to the source path and never callsresolveTargetPathat all) both inherit it, and a future call site cannot forget it. A bucket rooted in.github/now fails at the source-file walk with exit 6 and a message naming the directory, before any translation request; the pattern-level check is kept as well. Segments are compared relative to the project root, so a checkout living under a.githubdirectory is unaffected, and.gitlab/,.gitignoreand paths merely containinggithubas a substring are untouched. - sync: Glob patterns from
.deepl-sync.yamlare bounded before they reach fast-glob, so a hostile checkout can no longer end the process with an uncatchable out-of-memory abort. fast-glob expands brace groups throughbraces, which caps only its input length while the expansion it produces is a product with no bound at all: a 1007-byteincludepattern of 200{a,b}groups killeddeepl syncanddeepl sync validatewithFATAL ERROR: Ineffective mark-compacts near heap limit(SIGABRT, exit 134) — and an abort is not a JavaScript exception, so the per-bucket error handling could not contain it, and the tool's own generated pre-commit hook and documented CI step were killed the same way. Because the expansion is a product, this needs very little input: 20 groups is 107 characters and already wedges the run. Every pattern-bearing field is now checked at config load —buckets.*.include,buckets.*.exclude, top-levelignoreandcontext.scan_paths— rejecting anything that expands past 1000 paths or exceeds 4096 characters with aConfigError(exit 7) naming the field. The bound is a conservative over-approximation computed without expanding anything, counts numeric and alpha ranges ({1..9},{a..e}) by cardinality, and folds unbalanced groups in as if closed so an unclosed{cannot smuggle a bomb past it. Neither cap is configurable, since the attacker in this scenario supplies the config, and realistic patterns are nowhere near it ({en,de,fr}/**/*.{json,yaml,yml}expands to 9). - sync: Bucket
includeglobs can no longer escape the project root, and--dry-runno longer modifies the working tree.includeentries were validated only as non-empty strings whiletarget_path_patterna few lines later already rejected.., and the unvalidated glob's literal prefix was resolved and handed to the stale-.baksweep, which recursed with no containment check — deleting every old*.bakit found and re-creating any file whose.bakexisted while the live file was missing or empty. Verified:include: "../../../../../../**/*.json"produced a sweep root of/var, an out-of-root.bakwas deleted and its sibling resurrected with the backup's contents. Two things made it worse: the sweep was gated only on watch runs, so it ran under--dry-run— the flag a cautious user reaches for to avoid side effects — and its errors were swallowed entirely.includeentries are now rejected at config load for traversal segments and absolute paths (the check thesync initwizard already applied and the load path did not), the sweep independently refuses any root outside the project and logs the attempt, it is skipped under--dry-run, and its failures are reported instead of discarded. - sync: Target-path containment is enforced before any target file is read or backed up. The check previously ran after the read and the
.bakcopy, so a committed symlink directory plus a craftedtarget_path_patterncould read an out-of-root file into memory and clobber an out-of-root.baksibling before the write was blocked — and the swallowed error made it repeat per locale × file. A containment violation now aborts the sync instead of being absorbed, and the bucket pre-read loop, which had no containment check at all, asserts it too. - sync:
source_localeandtarget_localesare validated against a BCP-47 whitelist at config load (previously a three-substring denylist), andtarget_path_patternmay not contain a.gitor.githubpath segment — closing a write primitive where a "locale" likeconfigplus a pattern like.git/{locale}wrote inside.git/. Migration: underscore-style locale codes (pt_BR) are now rejected; use hyphenated BCP-47 (pt-BR). - sync:
.deepl-sync.yamldiscovery stops at the repository boundary (the first directory containing.git) instead of walking to the filesystem root, so a config planted in an ancestor directory outside the repo is no longer silently adopted as project root. - translate/sync: Placeholder restoration no longer hangs the CLI with unbounded memory growth.
restorePlaceholdersloopedwhile (restored.includes(placeholder)), replacing one occurrence per pass, so when the preserved original itself contained the token every pass re-inserted it and the guard never went false — the input{__VAR_0__}grew from 9 to 400,009 bytes across 200,000 iterations without converging. It needed no attacker and no network:preserveVariables' pattern matches that shape, variable preservation runs unconditionally, and restoration also runs on cached results, so a locale value of that shape hung the process with no API call. Each placeholder is now restored in a single pass, using the function form of the replacement so$&/$1inside a preserved value stay literal. - formats: A translated Android string can no longer break out of its CDATA section.
escapeForReconstructwrapped the translation in a CDATA section with no escaping, so a value containing]]>closed the section early and the remainder was parsed as XML, allowing extra<string>elements into a generated resource file. This was reachable without a malicious API response, since on translation failure the source string is written through verbatim and the source file is the template when the target locale file does not exist yet. Occurrences of]]>are now split across adjacent CDATA sections, which keeps the text literal, and extract concatenates adjacent sections so such values round-trip unchanged. - formats: The YAML i18n parser no longer expands aliases at all, structurally removing the denial-of-service vector where documents with exponentially expanding anchors ("alias bombs") or self-referential anchors hung
deepl syncindefinitely. Aliased content is extracted and translated only at its anchor site and every alias, merge keys included, round-trips as a reference, so alias bombs now parse in milliseconds as plain references instead of being rejected by an expansion budget. - tests: The test suite no longer inherits real credentials or the real config directory. Suites that spawn the bare
deeplcommand cannot be intercepted by nock, so they reached the live DeepL API with whatever key was exported and read and wrote the developer's cache database — cached responses matching this suite's fixtures were recovered from a real cache, confirming it had happened.globalSetupnow clearsDEEPL_API_KEY,TMS_API_KEYandTMS_TOKENand pointsDEEPL_CONFIG_DIRat a temporary directory before workers fork. - init:
deepl initmasks the API-key prompt instead of echoing the key in cleartext into terminal scrollback. - glossary: Server-supplied glossary names are sanitized before terminal display, blocking ANSI escape injection via glossaries shared within a team account; style-rule names were already sanitized and the two sites now match.
- batch:
translate --patternvalues containing..can no longer write translated output outside--output-dir— the default output branch now enforces the same containment as--output-pattern— and directory batch translation no longer follows symlinks out of the input directory. - deps:
brace-expansionis pinned to>=5.0.9through anoverridesentry, resolving GHSA-rgw5-rvv9-x895 (unbounded intermediate arrays) in the copy reached viaminimatch; it is anoverridesentry rather than a dependency because the CLI does not import the package and declaring it would failcheck-deps. Earlier lockfile-only bumps in this release resolved GHSA-jxxr-4gwj-5jf2 (ReDoS), GHSA-3jxr-9vmj-r5cp and GHSA-mh99-v99m-4gvg (exponential-time expansion and unbounded expansion length) in the same package, GHSA-hmw2-7cc7-3qxx inform-data(CRLF injection), and GHSA-58qx-3vcg-4xpx / GHSA-96hv-2xvq-fx4p inws(uninitialized-memory disclosure and memory-exhaustion DoS). Productionnpm auditis back to zero vulnerabilities. Dev-tree instances of the brace-expansion advisories are intentionally left in place: npm's proposed remediation downgradesjest30 → 25 andts-jest29 → 27,devDependenciesare not installed by consumers, and the CI audit gate is production-only. - ci:
ci.ymlandsecurity.ymlexplicitly requestcontents: readinstead of inheriting the repository default token permissions.
- write: Japanese (
ja), Korean (ko), and Simplified Chinese (zh,zh-Hans) are now accepted target languages fordeepl write. - write:
--toneand--stylenow apply to Spanish (es), Italian (it), French (fr), and Portuguese (pt,pt-BR,pt-PT) in addition to the previously supported locales. The full value lists on--toneand--styleare unchanged — the same 9 styles and 9 tones are accepted, and the mutual-exclusion rule between--styleand--toneis unchanged. Seedocs/API.mdfor supported target-language / style / tone combinations. - write: 4xx responses from the Write API that arrive while
--styleor--toneis set now carry an explicit recovery hint pointing atdocs/API.mdfor supported target-language / style / tone combinations. - style-rules: Full CRUD —
deepl style-rules create|show|update|deletealongside the existinglist.createrequires--nameand--language.updateaccepts--namefor a rename and--rulesfor replacing configured rules (PUT/configured_rules); at least one is required.--rulestakes a JSON object of category → settings, e.g.'{"punctuation":{"quotation_mark":"use_guillemets"}}'— matching the DeepL API's two-level rule shape.deletesupports-y/--yes,--dry-run, and a TTY confirmation prompt. All new subcommands support--format text|json. Text-format output sanitizes control characters from rule names, configured-rule keys/values, and instruction text. - style-rules: Custom instructions management —
deepl style-rules instructions <style-rule-id>(list, synthesized from the detailedshowresponse), plusadd-instruction <style-id> <label> <prompt>,update-instruction <style-id> <label> <prompt>, andremove-instruction <style-id> <label>subcommands.remove-instructionships-y/--yes,--dry-run, and a TTY confirmation prompt (custom instructions are user-authored text and deserve confirmation before deletion). - style-rules:
deepl style-rules listanddeepl style-rules instructions <style-rule-id>accept--format tablefor aligned column output viacli-table3, matching the existingtranslate,languages,usage, andcachecommands. In non-TTY output (pipe, redirect, CI), table falls back to plain text with aWARNline on stderr — same pattern used bydeepl translate --format table. - examples:
examples/35-style-rules-crud.sh— end-to-end style-rules workflow (create → show → update rules → add custom instruction → update instruction → remove instruction → delete rule). - examples:
examples/36-write-extended-languages.sh—deepl writewith Japanese, Korean, and Simplified Chinese targets and tone / style applied to Spanish, Italian, French, and Portuguese variants.
- languages / cache stats / usage:
--format tablenow actually renders acli-table3table on these commands. Previously the flag was advertised in--helpbut the action handler only branched on'json', so--format tablesilently produced text output. Same non-TTY fallback as the other table commands. - sync:
deepl sync export --output <path>(and other sync surfaces that callassertPathWithinRoot) no longer reject valid output paths under a project root that contains a symlink in its ancestor chain. The containment check now resolves both sides throughfs.realpathbefore comparing, so a project under macOS/tmp(a symlink to/private/tmp) — or any other symlinked directory — works regardless of which form the user types or the engine captures. Symlink-based escape attempts (a symlink inside the project pointing outside) are now also rejected as a defense-in-depth bonus.
- api: Server-returned error messages are now passed through
sanitizeForTerminalbefore being interpolated into the user-facingAPI error: …andServer error (5xx): …strings emitted fromsrc/api/http-client.ts. Defense-in-depth against a buggy or malicious server scribbling ANSI escape codes or other terminal control characters on the user's terminal via the error path. Mirrors the existing TMS-client hardening; no change to error-message wording when the server payload is well-formed.
- Exit Codes appendix in docs/API.md enumerating all CLI exit codes with emitting commands.
- Continuous localization sync engine (
deepl sync) for scanning, diffing, and translating i18n resource files - 11 i18n file format parsers: JSON, YAML, Gettext PO, Android XML, iOS Strings, ARB, XLIFF, TOML, Java Properties, Xcode String Catalog, Laravel PHP arrays
- Xcode String Catalog (
.xcstrings) format parser for iOS/macOS projects — multi-locale, comment preservation - sync: Laravel PHP arrays (
.php) format parser withglayzzle/php-parser— AST allowlist over string-literal return-array entries; double-quoted interpolation ("Hello $name"), heredoc, nowdoc, and string concatenation are rejected with aValidationError. Reconstruct is span-surgical (AST offsets only; every byte outside a replaced string literal is preserved verbatim — comments, PHPDoc, trailing commas, irregular whitespace, and quote style all round-trip unchanged). Laravel pipe-pluralization values (|{n},|[n,m],|[n,*]) are detected at extract, excluded from the translation batch, and surfaced indeepl sync statusvia a newskippedKeyscount.php-parseris lazy-loaded only when alaravel_phpbucket is configured. - sync:
deepl sync initauto-detects Laravel projects —composer.jsonat the repo root plus.phpfiles underlang/en/(Laravel 9+) orresources/lang/en/(Laravel ≤8 / Lumen) triggers alaravel_phpbucket suggestion. Filesystem-only (no manifest parsing), consistent with the Rails / Django / Flutter / Angular detectors. - sync: The auto-detect engine now supports optional root-marker files via a
requiresfield on each detection pattern. Markers are plainfs.existsSyncchecks — never parsed — matching the filesystem-only stance of the sibling detectors. Laravel'scomposer.jsonis the first required marker; the ARB (Flutter) detector was retroactively tightened withpubspec.yamlto eliminate false positives for the very rare non-Flutter ARB use. - sync:
deepl sync initnow auto-detects four additional ecosystems that the docs previously promised but the detector never actually covered: Rails (config/locales/en.yml/.yaml), Xcode String Catalog (Localizable.xcstrings/Resources/Localizable.xcstrings/*.xcstrings, multi-locale), go-i18n TOML (locales/en.toml,i18n/en.toml), and Java / Spring properties (src/main/resources/messages_en.properties). Also fixes a pre-existing extension-preservation bug in the YAML detector — alocales/en.ymlmatch used to emit alocales/en.yamlbucket pattern that wouldn't match at sync time..ymland.yamlare now handled as separate detection entries so the extension round-trips faithfully. - sync:
deepl sync initauto-detects go-i18n's root-levelactive.en.tomllayout as a dedicated detection entry, emitting theactive.{locale}.tomlfilename template. Previously onlylocales/en.toml/i18n/en.tomldirectory layouts were covered; root-level users had to fall through to the four-flag non-interactive path. - sync:
deepl sync initauto-detects Rails namespaced layouts underconfig/locales/**/en.yml(and.yaml) — engines, concerns, and per-namespace splits are now recognized alongside the canonicalconfig/locales/en.yml. The namespace directory is preserved in the generated bucketinclude:pattern. - sync:
deepl sync initauto-detects Symfony'stranslations/messages.en.xlflayout as a dedicated XLIFF detection entry — distinct from Angular'ssrc/locale/messages.xlfconvention. Target locales are emitted astranslations/messages.{locale}.xlf. - sync:
sync.limitsconfig block — per-file parser capsmax_entries_per_file(default 25 000, hard max 100 000),max_file_bytes(default 4 MiB, hard max 10 MiB),max_depth(default 32, hard max 64). Default-exceed = file-skip + warn; setting a value above the hard ceiling fails at config load withConfigError(exit 7). - Multi-locale format support in sync engine (
FormatParser.multiLocale) for single-file formats like.xcstrings - Incremental sync with change detection via
.deepl-sync.lockcontent hashing - Interactive setup wizard (
deepl sync init) with framework auto-detection (i18next, Rails, Django, Flutter, Angular, etc.) - Translation coverage reporting (
deepl sync status) with per-locale progress bars - Translation validation (
deepl sync validate) for placeholder, format string, and HTML tag integrity sync exportcommand — export source strings to XLIFF 1.2 for CAT tool handoff- Auto-context extraction from source code for improved translation quality, including template literal calls (e.g.,
t(`features.${key}.title`)) - Key path context synthesis — i18n key hierarchy (e.g.,
pricing.free.cta) is parsed into natural-language context descriptions sent to the DeepL API - Element type detection — HTML/JSX element types (button, h2, th, etc.) are extracted from surrounding source code during context scanning
- Element-aware custom instructions — auto-generated
custom_instructionsfor 16 element types (button, a, h1-h6, th, label, option, input, title, summary, legend, caption), batched by element type for efficient API usage. Only for the 8 locales supporting custom instructions (DE, EN, ES, FR, IT, JA, KO, ZH) translation.instruction_templatesconfig — user-customizable instruction templates per HTML element type, overriding built-in defaultstranslation.length_limitsconfig — opt-in length-aware translation instructions using per-locale expansion factors based on industry-standard approximations (IBM, W3C); user-overridable- Section-batched context translation — keys sharing the same i18n section (e.g.,
nav.*) are batched with shared section context, ~3.4x faster than per-key while preserving disambiguation quality - Translation strategy summary in sync output — shows how many keys used context, instructions (by element type), or plain batch translation
- Config warnings when
instruction_templatesis set but context scanning is disabled or no element types are detected --batch/--no-batchCLI flags —--batchforces plain batch (fastest, no context);--no-batchforces true per-key context (slowest, max quality); default uses section-batched contextPushResult/PullResulttypes forsync push/sync pull— return{pushed|pulled, skipped[]}so callers can distinguish truly-nothing-to-do from silently-dropped cases. CLI output now appends(N skipped: ...)when appropriate.- Actionable TMS authentication errors — 401/403 responses from the TMS server now surface as
ConfigErrorwith a remediation hint that namesTMS_API_KEY/TMS_TOKENand the relevant.deepl-sync.yamlfields. context_sentfield in lockfile translation entries — records whether source code context was included in the API requestcharacter_countfield in lockfile translation entries — records characters billed per key per locale- Live progress output during
deepl sync— per-keykey-translatedevents during translation and per-localelocale-completeevents when each locale finishes, in both text and JSON formats context.overridesconfig — manual context strings per key, preferred over auto-extracted context- Auto-glossary management from translation history
- Optional TMS integration (
deepl sync push/pull) for collaborative editing and human review workflows; documented REST contract lets any compatible TMS be wired up - CI/CD integration with
--frozenmode and exit code 10 for translation drift detection validation.fail_on_missingandvalidation.fail_on_staleconfig options for granular--frozendrift detection- Dry-run mode (
deepl sync --dry-run) with character and cost estimates from source string lengths - Per-locale progress display after sync (
✓ de: 10/10 ✓ fr: 10/10) estimatedCharactersandtargetLocaleCountfields in JSON output- Dollar cost estimates in sync output and JSON (at DeepL Pro rates, $25/1M chars)
sync.max_charactersconfig option — cost cap that aborts sync before translation if estimated characters exceed limit (override with--force)sync.backupconfig option — pre-overwrite backup of target files (defaulttrue);.bakfiles cleaned up after successful sync--watchmode — monitors source i18n files for changes and auto-syncs with debouncing (configurable via--debounce)--flag-for-reviewmarks MT translations withreview_status: machine_translatedin the lock file for human review workflows- Free API key (
:fxsuffix) support with automatic endpoint resolution toapi-free.deepl.com - Custom/regional endpoint support (e.g.
api-jp.deepl.com) that takes priority over auto-detection sync export --overwriteflag — required to overwrite an existing--outputfile; protects against accidental clobberingdeepl sync status --format jsonerror-mode output: failures now emit{error, code}JSON to stderr with the error class name (ConfigError,ValidationError, etc.) as thecode- Translation memory support in
deepl translatevia--translation-memory <name-or-uuid>and--tm-threshold <n>— forcesquality_optimizedmodel, requires--from(pair-pinned), threshold is an integer 0–100 (default 75) - Translation memory support in
deepl syncviatranslation.translation_memoryandtranslation.translation_memory_thresholdconfig keys, with per-locale overrides undertranslation.locale_overrides - Translation memory name-to-ID resolution is cached per run to avoid redundant
GET /v3/translation_memoriescalls; TM files are authored and uploaded via the DeepL web UI - Verbose-mode logs at the glossary and translation memory resolution boundary:
--verbosenow shows the resolved UUID for each glossary or TM name ([verbose] Resolved glossary "<name>" -> <uuid>,[verbose] Resolved translation memory "<name>" -> <uuid>) and a cache-hit line when the same TM name + pair is reused within a session deepl tm listsubcommand — lists all translation memories on the account, mirroringdeepl glossary list. Text output filters control chars and zero-width codepoints from TM names so a malicious API-returned name cannot corrupt the terminal;--format jsonemits the rawTranslationMemory[]as returned byGET /v3/translation_memories. Help text ondeepl translate --translation-memorynow cross-references the new commandsrc/utils/uuid.ts— shared strict UUID regex (UUID_RE) +validateUuid/validateTranslationMemoryIdhelpers.validateTranslationMemoryIdis dormant today (TM IDs only appear in/v2/translatePOST bodies, which are JSON-escaped) but guards the path-injection surface the moment any future per-TM endpoint interpolates a user-supplied UUID into a URL segment- sync:
deepl sync resolvenow prints a per-entry decision report (kept ours/kept theirs/length-heuristic/unresolved) plus a summary, and accepts--dry-runto preview decisions without writing the lockfile. - sync docs:
docs/SYNC.mdExit Codes table anddocs/API.mdsync Behavior bullet now cross-link to the canonical Exit Codes appendix. - sync: New
sync.max_scan_filesconfig key (default 50,000). - errors:
SyncConflictErrorclass insrc/utils/errors.tsmirroringSyncDriftError—ExitCode.SyncConflict(11) is now throwable as a typed error so library consumers caninstanceof-match the conflict case. - SECURITY.md:
1.1.xrow added to the Supported Versions table. - CONTRIBUTING.md: PR checklist reminds contributors to register new example scripts in
examples/run-all.sh. - .github/ISSUE_TEMPLATE/:
bug_report.mdandfeature_request.mdtemplates for structured issue intake. - write:
deepl write --to <language>is now accepted as a long-only alias of--lang. The alias exists so users can reach for--touniformly acrossdeepl translateanddeepl write— the single most common vocabulary split flagged in cross-command usage.--lang/-lremain fully supported; nothing deprecated. The short form-tis intentionally not bound onwrite(it would collide withdeepl translate -t, --to). Passing both--toand--langwith different values exits with aValidationError; passing the same value works fine. - docs:
docs/API.mdgained a one-paragraph callout distinguishingdeepl sync --locale(filter over locales already configured in.deepl-sync.yaml#target_locales) fromdeepl translate --to(invocation-time target-language specifier). The split is semantic — sync owns its locale mapping via config; translate does not — and documenting the distinction is the right fix rather than forcing one to compromise for surface symmetry. - sync: new
sync.limits.max_source_filesconfig field. Caps how many source files a single bucket'sincludeglob may match before the bucket is skipped with a warning. Default10000, hard ceiling1000000. Guards against a misconfigured**/*.jsonthat accidentally picks up a vendored subtree. Sibling fieldsmax_entries_per_file/max_file_bytes/max_depthgate individual files; this one gates the whole bucket because processing the first N of an oversized glob would silently drop the rest. Narrow the pattern or raise the cap in.deepl-sync.yaml.
Note: deepl sync intentionally exposes no --translation-memory / --tm-threshold CLI override in this release; configure translation memory via .deepl-sync.yaml.
deepl synccost estimates now labeled as Pro tier: text-mode output appends(Pro tier estimate)to all cost lines (both dry-run and post-sync). The--format jsonoutput carriesrateAssumption: "pro".docs/SYNC.mdnow documents the Pro-rate assumption ($25/1M chars) and points users to their account page to determine the applicable rate for their tier.deepl sync --format jsonoutput contract stabilized: the success JSON payload is now a curatedSyncJsonOutputshape (ok,totalKeys,translated,skipped,failed,targetLocaleCount,estimatedCharacters,estimatedCost?,rateAssumption: "pro",dryRun,perLocale[]) instead of a raw internal spread. The public shape is documented in docs/API.md and guaranteed stable across 1.x.deepl sync initno-detection exit: when the auto-detector finds no recognized i18n files, the command now exits 7 (ConfigError) instead of 0, and prints an actionable remediation hint listing all four required flags (--source-locale,--target-locales,--file-format,--path). In--format jsonmode the canonical error envelope ({ok:false, error:{code:"ConfigError",...}, exitCode:7}) is emitted to stderr. Scripts that previously relied on exit 0 in empty projects must be updated to handle exit 7.deepl sync statusdocumentation: the docs/SYNC.md example output now matches the actual CLI output — ASCII progress bar ([####....]), integer coverage percentage, and per-locale(N missing, N outdated)parenthetical. The previous example showed Unicode block characters, decimal percentages, and aTranslation Status:header that the code never emits. The per-localeoutdatedfield is now documented in the JSON field legend.- sync:
deepl sync initnow prefers the dir-per-locale JSON layout (locales/en/*.json) over the flat layout (locales/en.json) when both coexist in the same repo. The init wizard'sdetected[0]selection was silently picking the flat entry, which is usually legacy / sample content while the nested layout is the real source — i18next, react-i18next, and next-i18next all default to nested. Both entries remain inDETECTION_PATTERNSfor enumeration; only the first-pick order changed. - translate: Centralize
TranslateOptionsconstruction fordeepl translate,deepl translate file.txt,deepl translate <dir>, and the document path in a newsrc/cli/commands/translate/translation-options-factory.ts. All four handlers now callbuildBaseTranslationOptions()+applySharedTmAndGlossary()instead of each maintaining its own copy of the base mapping plus a near-identical TM/glossary resolution block. Behavior-preserving for the shared flags (--formality,--glossary,--model-type,--translation-memory,--tm-threshold,--preserve-formatting); fixes latent drift risk where one handler could silently diverge from another. Handler-specific shaping (custom instructions, style id, XML tag handling, multi-targettargetLangstripping) stays in the handler.deepl syncis intentionally untouched — itsTranslationOptionsare built from resolved config with per-locale overrides andcontext_sentwiring, a different construction domain that lives insrc/sync/sync-locale-translator.ts. - sync: Format-name knowledge consolidated under
src/formats/registry.ts;--file-formatCLI choices now derive from the registry. Prevents silent divergence between parser, CLI help, and registration. - sync: Removed per-parser
sortcalls (consumers sort once); extracteddetectIndentto a sharedsrc/formats/util/detect-indent.tsused by JSON, ARB, and xcstrings. Pure refactor, no behavior change. - sync:
scan_pathsfile walk is now bounded (default 50,000 files; configurable viasync.max_scan_filesin.deepl-sync.yaml) — exceeding the cap throws ValidationError with a suggestion, preventing CI wedges on misconfigured patterns. - sync:
deepl sync push --helpanddeepl sync pull --helpnow include a TMS onboarding hint — the requiredtms:YAML block, theTMS_API_KEY/TMS_TOKENenv vars, and a pointer todocs/SYNC.md#tms-rest-contract. Previously the help surface listed only--locale/--sync-config, so users had to run the subcommand once and read a runtime ConfigError to discover the integration requirements.docs/API.mdpush/pull sections get the same hint and cross-link. - sync:
deepl sync --forcehelp text now warns that the flag bypasses thesync.max_characterscost-cap preflight and can incur unexpected API costs by rebilling every translated key. Previous wording ("Retranslate all strings, ignoring lock file") described the lockfile effect but was silent on the billing surprise.docs/API.mdanddocs/SYNC.mdupdated to match. - sync: Extract CLI exit-code enum to
src/utils/exit-codes.ts(next to the errors module); addsSyncConflict(11) forsync resolveunresolvable-conflict exits. No runtime behavior change from the extraction alone; enables the envelope contract wiring. - sync:
deepl sync initflag vocabulary aligned with the rest of sync:--source-localeand--target-localesare now the primary names, matching--localeinsync push/pull/status/export.deepl translate --target-langis unchanged (operates on strings, distinct from locale-file semantics). - sync: Rename
deepl sync --context/--no-contextboolean to--scan-context/--no-scan-contextto disambiguate fromdeepl translate --context "<text>"(string-valued). Bare--context/--no-contexton sync now errors with a did-you-mean pointing to the new flag.deepl synchad not shipped in a tagged release prior to this change, so no deprecation cycle is needed. - sync: CLI override layering (
--formality,--glossary,--model-type,--scan-context,--batch/--no-batch) is now centralized in a singleapplyCliOverrideshelper insync-config.ts. The TM-requires-quality_optimizedguard now also fires at the CLI-override boundary, so--model-type latency_optimizedis rejected with an actionableConfigErrorwhen the loaded YAML hastranslation_memoryset (previously the override silently bypassed the check). - sync:
deepl sync glossary-reportis renamed todeepl sync audit. Every other sync subcommand is a single action verb (init,status,validate,export,resolve,push,pull); the hyphenated noun-phrase was an outlier and a name mismatch (the command detects terminology inconsistency whether or not a glossary is configured). The old form is rejected with aValidationError(exit 6) and a did-you-mean hint pointing toaudit. No deprecation alias — this is a pre-release rename;glossary-reportnever shipped in a tagged release.audithere means translation-consistency audit (term divergence across locales), not security audit in thenpm auditsense. - sync: Lockfile writes now serialize in-place without deep-cloning; a 10K-key × 10-locale lockfile peaks at ~2× rather than ~3× its serialized size. Watch-mode sync runs that write on every tick see the same reduction.
- sync:
deepl sync initinteractive wizard now offers the full DeepL target-locale set (~25 locales) in the checkbox prompt, with 8 common locales pre-checked. Previously the wizard exposed only de/es/fr/ja/zh. - sync: Default context translation mode: keys with auto-extracted context are now section-batched instead of per-key. Use
--no-batchto restore per-key behavior. - sync:
deepl sync status --format jsonoutput shape declared stable across 1.x —{sourceLocale, totalKeys, locales[]}withcoverageas an integer 0-100. CLI JSON uses camelCase; on-disk lockfile/config use snake_case. - endpoint: Shared endpoint resolver now used by all commands including voice, auth, and init.
- docs: Corrected Watch Mode section of
docs/SYNC.md—.deepl-sync.yamlIS watched and triggers a config hot-reload on change (was documented as not watched); CLI flags (--locale,--dry-run,--formality,--glossary, etc.) are baked at invocation and do NOT reload between cycles (was documented as re-read each cycle); added SIGHUP force-reload behavior (previously undocumented). - sync: TMS credential-hygiene warnings now route through
Logger.warn(respects--quiet, consistent with the rest of the CLI and flowing through the Logger sanitizer). - CLAUDE.md: Architecture block refreshed to include
sync/,formats/,data/layers; drift-prone version/test-count metadata replaced with references toVERSION/package.jsonandnpm testoutput. - README: Featured
deepl sync(Continuous Localization) prominently in Key Features. - README: Voice Translation Key Features bullet now labeled
(Pro/Enterprise). - README: Quick Start version-output example replaced with schematic
deepl-cli 1.x.x(no longer drifts per release). - README: Configurable timeout/retry copy reworded — now described as library-consumer options, not exposed as a CLI flag.
- README: "GDPR compliant" softened to "GDPR-aligned with DeepL's DPA" for legal precision.
- README: DeepL® trademark attribution appended to the License section.
- README:
deepl initsection cross-links todeepl sync initfor continuous-localization setup. - sync exit codes:
deepl syncpartial-failure (one or more locales failed while others succeeded) now exits 12 instead of 1. Exit 1 now means strictly "unclassified CLI failure." A prior version aliasedExitCode.PartialFailuretoGeneralError(both1), which prevented CI scripts from telling a partial sync outcome from a CLI crash. With this change, CI can safely branch on$? -eq 12and retry only the failed locales viadeepl sync --locale <failed,comma,separated>. The paired typed error classSyncPartialFailureError(exit 12, envelopecode: "SyncPartialFailure") is added tosrc/utils/errors.ts, mirroringSyncDriftError(10) andSyncConflictError(11). Migration: any CI script that branched on$? -eq 1to detect partial sync failure should switch to$? -eq 12; a generic$? -ne 0check continues to work unchanged. - sync drift exit:
deepl sync --frozennow exits soft (setsprocess.exitCode = 10and returns from the action handler) instead of callingprocess.exit(10)directly. Observable exit code is unchanged at 10; the internal change lets in-flight writes, auto-commit steps, and any--watchevent loop drain cleanly before the process exits.docs/API.mdhas promised this shape since 1.1.0 but the implementation drifted to a hard exit — now aligned. - tests: The shared
tests/setup.tsafterEachhook now asserts that everynockinterceptor registered during a test actually fired. An unasserted mock (registered scope with no matching request) now throws with the pending interceptor list, surfacing silent test gaps where the SUT never exercised the mocked network call. No test changes were required — the existing suite (49 integration files, 766 tests, plus unit + E2E = 4501 tests) already had clean hygiene. Negative-path tests that intentionally register non-firing interceptors can opt out by callingnock.cleanAll()from their ownafterEachbefore the shared hook runs. - cache: SQLite cache DB now carries a schema version via
PRAGMA user_version. Fresh DBs are stamped at version 1; pre-versioned DBs (created before this field existed) report 0 and are upgrade-stamped in place — no data migration, no user-visible change. Opening a DB whose version is newer than the CLI supports now fails with aConfigErrorrather than risking data loss. - cache: Corrupted cache databases are now backed up aside as
cache.db.corrupt-<timestamp>(plus any-wal/-shmsidecars) instead of being unlinked. Users keep their 30-day cache contents and a forensic artifact for post-mortem; the CLI creates a fresh DB alongside and continues.Logger.warnnames the backup path. - http: Retry backoff now uses full jitter (AWS-recommended variant): the delay for attempt
nis a uniform random value in[0, min(INIT * 2^n, MAX)]rather than the fixedmin(INIT * 2^n, MAX). Concurrent clients (e.g., parallel sync buckets) that all hit 429 at the same moment no longer form a thundering herd on the retry. TheRetry-Afterheader path is unchanged — server-specified delays are honored verbatim. - http: Retries now emit a
Logger.verboseline per retry decision naming the attempt number, delay, and reason (429 with Retry-After, 429 with jitter backoff, or generic network error). Previously retries were silent; a user seeing elevated latency had no visibility into whether the CLI was backing off or stuck.
- sync:
deepl sync init --source-langand--target-langsare deprecated in favor of--source-localeand--target-locales. The old flags continue to work but emit a stderr deprecation warning; they will be removed in the next major release.
- sync: Dead
onProgresscallback andSyncProgressEventinterface fromSyncOptions(never wired up). - sync: Remove silently-ignored
--batch-sizeflag - sync: Remove 5 unimplemented config fields from types and docs
- package.json: Drop
exports["./cli"]subpath. It pointed atdist/cli/index.js, which runsprogram.parseAsync+process.exitat module load — any consumer who importeddeepl-cli/cliwould have had their own process terminated mid-import. The CLI remains available as a binary via thebinfield.
- sync cost cap: When a brand-new target locale is added to an existing project,
sync.max_charactersnow correctly includes the character cost of translating all current keys into the new locale in its preflight estimate. Previously,toTranslatewas empty (no new/stale diffs) so the cap check passed with 0 estimated characters while the actual sync translated the entire key set — a silent cost surprise. The live-path preflight now mirrors the dry-run math (currentChars × newLocaleCount) so--dry-runand the live run always report the same estimated character count for the same workload. - sync perf: Stale-lock entry cleanup now issues a single
fgcall with all stale-basename patterns instead of one call per stale entry. A reorg renaming 50 files previously triggered 50 sequential full-tree scans before sync completed; it now completes in one pass regardless of stale-entry count. - sync perf: Startup
.baksweep (sweepStaleBackups) is now scoped to the directories implied by each bucket'sincludeglobs instead of walking the entire project tree. On large monorepos the sweep cost is now proportional to the number of bucket-matched directories rather than total project size. Callers without bucket config fall back to the previous full-tree walk with a one-time warning. deepl sync push --format json,deepl sync pull --format json, anddeepl sync resolve --format jsonnow emit a JSON success envelope to stdout on the happy path ({ok:true, pushed/pulled/resolved: N, skipped/decisions: [...]}) instead of silently writing nothing; scripts piping output to a file no longer receive an empty result.- sync: Eliminated O(F×K)
resolveTemplatePatternsloop over duplicate template-pattern entries. The accumulator inextractAllKeyContextspushed oneTemplatePatternMatchper template-literal match per source file with no dedup; a 2K-file repo with 20 template literals per file produced 40K entries × 10K keys = 400M.test()calls (~8s/sync). ASet-based dedup before the resolve loop collapses all per-file duplicates to at most one entry per distinct pattern string;MAX_LOCATIONS=3downstream is unaffected since the first-seenfilePath/lineis sufficient context. - sync: Eliminated O(N²)
Array.includesscan in the per-locale plural-slot hot path (sync-locale-translator.ts). Three call sites that testedbatchIndices.includes(slot.diffIndex)— one in Path A (plain batch), one in Path C (element-instruction batch), one in Path B1 (section-batched context) — now precompute aSetbefore thepluralSlotsloop and useSet.has. With 5K plural entries, 50 locales, and a 200-file repo the old code added ~40 min of pure array-scan overhead per sync run. - sync:
deepl sync pushanddeepl sync pullCLI summary lines now render a per-reason breakdown when entries are skipped (e.g.,(4 skipped: 1 target file not yet present, 2 pipe-pluralization (never sent to TMS), 1 no matching keys)) instead of a single stale message. Afterpipe_pluralizationwas added as a thirdSkipReason, the previous "target file not yet present" / "no matching keys" strings were incorrect for Laravel users hitting the pipe-plural skip. Logic extracted to a sharedformatSkippedSummary(skipped)helper insync-tms.ts; the programmaticPushResult.skipped/PullResult.skippedshape is unchanged. - sync:
deepl sync pushanddeepl sync pullnow enforce the walker's skip-metadata partition at every inlineparser.extract(...)site (multi-locale source, non-multi-locale target file, pull-merge template). Laravel pipe-pluralization values (|{n},|[n,m],|[n,*]) were leaking past the partition on push (sent verbatim toTmsClient.pushKey, where the TMS would store them as a single malformed string) and on pull merge (overwriting the preserved pipe-plural target value with the single-string TMS payload, corrupting Laravel's pluralization syntax). A new exportedpartitionEntrieshelper insync-bucket-walker.tsis applied at the three callsites,TmsClient.pushEntry()now rejects skip-tagged entries at the client boundary so pipe-plural values cannot reach the TMS even if a caller forgets to partition, andPushResult/PullResultnow surface aSkippedRecordwithreason: 'pipe_pluralization'andkeyper leaked entry so silent-partition regressions are detectable. - sync:
deepl sync initJSON detector now emits a glob bucket pattern for the directory-per-locale i18next layout (locales/en/*.json) instead of fabricating a nonexistentlocales/en/en.jsonsingle-file path. Flat (locales/en.json) and dir-per-locale layouts are now separate detection entries. - sync:
deepl sync initiOS detector no longer claims bare-root*.stringsfiles — Apple's bundle model mandates.lproj, and the root-level glob was a relocation magnet that emitted{locale}.lproj/Localizable.stringstarget patterns pointing at paths the source never lived in. Projects with that layout now fall through to the four-flag non-interactive init path. - sync:
deepl sync initXLIFF detector no longer claims bare-root*.xlf/*.xlifffiles — CAT-tool dumps (Trados/memoQ/Xcode.xclocextracts) are a false-positive magnet and the detector used to relocate them undersrc/locale/. Canonical Angular layouts (src/locale/messages.xlf) are unchanged. - sync: TOML parser reconstruct is now span-surgical — comments, blank lines between sections, per-value quote style (double vs literal), key order within a section, and irregular whitespace around
=all round-trip byte-identically. Previouslyreconstruct()ransmol-toml.stringify(data)on a mutated parse tree, silently discarding every# translator: …comment and collapsing blank lines on first sync — a content-loss regression users saw as noisy first-sync diffs. Multi-line triple-quoted strings remain pass-through (out of scope).smol-tomlis retained forextract(). - sync:
.deepl-sync.yamlnow rejects unknown fields at every nesting level (top-level, buckets, translation, context, validation, sync, tms, locale_overrides) with a ConfigError (exit 7) and a did-you-mean hint pointing at the closest known field. Previously typos were silently discarded — for example,target_locale: en(singular) produced a "missing target_locales" error with no pointer to the offending key. - build: Build pipeline now wipes
dist/before compilation (npm run clean && tsc) so file renames insrc/cannot leave orphaned.js/.d.tsfiles that would ship vianpm publish. - voice: Voice API no longer hardcodes the Pro endpoint; it follows the same endpoint resolution as all other commands.
- auth:
auth set-keyandinitnow validate entered keys against the correct endpoint based on key suffix. - endpoint: Standard DeepL URLs (
api.deepl.com,api-free.deepl.com) in saved config no longer override key-based auto-detection. - sync:
deepl sync push --locale <x>anddeepl sync pull --locale <x>now narrow the fan-out to the named locale instead of silently over-fetching every configured target. Commander was routing--localeto whichever scope declared it first, so the subcommand handlers receivedundefinedand treated the filter as absent. The subcommands now resolve--localevia a sharedresolveLocale(opts, command)helper that prefers the subcommand's value and falls back to the parentsync --locale, matching the existingresolveFormatpattern. - sync: Every sync subcommand now cleans up in-flight
.tmpand.baksibling files on SIGINT/SIGTERM (previously onlysync --watchhad this discipline), and sweeps stale.bakfiles older thansync.bak_sweep_max_age_seconds(default 300) at the start of each non-watch run. Reduces accumulation of orphaned artifacts in locale directories after crashes. - sync:
deepl sync --watchnow caches the validated sync config across debounced change events instead of reloading + revalidating it every tick. The cache invalidates onSIGHUP(explicit reload) or when.deepl-sync.yamlitself is one of the changed files. The watcher also tracks the config file itself so in-session edits are picked up automatically. Previously every file-change event paid for a YAML parse and full config validation even though config rarely changes during a watch session. - sync: Inline TMS credentials in
.deepl-sync.yaml(tms.api_key,tms.token) now produce astderrwarning at config-load time on everydeepl sync …subcommand, including non-TTY contexts like CI. Previously the warning was only emitted on thesync push/sync pullcode path, so a user runningsync statusor piping output through another tool would never see that their config held a secret. - sync: Section-batched context translation now honors the key-path separator the source format emitted. YAML keys (flattened with NUL) are now batched by section alongside JSON keys, and a literal dot in a flat YAML key (e.g.,
version.major: "1") is no longer mis-split into two sections by the section-batcher. - sync:
deepl sync initnow reports an accurate key count for every supported format, not just JSON and YAML. The detection step used to hard-code JSON/YAML parsing and silently fell back to0for Android XML, iOS Strings, PO, ARB, XLIFF, TOML, xcstrings, and Java Properties, so the wizard printed "Found 0 keys" for correctly-configured projects. Detection now routes through the FormatRegistry so key counts match what sync itself will extract. - sync: Remove duplicate per-locale tick output in default
deepl syncruns. Every completed (file, locale) pair was being printed twice — once live via thelocale-completeprogress event and again in a post-sync aggregated summary built fromfileResults. The aggregated summary is removed; the live tick is now the sole emission site, so the console reflects progress as it happens without a redundant end-of-run block. - sync: Per-key new-locale lookup in
LocaleTranslator.translateForLocaleis now O(1) (Map-indexed) instead of O(N) linear-scan. No user-visible behavior change; reduces cost on projects with large current-diff sets. - sync:
resolveTemplatePatternsnow compiles each distinct pattern regex once per sync run instead of once perTemplatePatternMatchoccurrence. Duplicate pattern strings (same template literal appearing in many source files) reuse the sameRegExp. - sync: Template-pattern prep no longer reads every source file twice during
deepl syncruns that use template-literal patterns. Source content is cached once at the pattern-resolution step and reused in the main translation loop. - sync:
push,pull,resolve,export,validate,audit, andinitnow emit a machine-parseable JSON error envelope on stderr when--format jsonis set and an error occurs:{ok: false, error: {code, message, suggestion?}, exitCode}. Previously these subcommands wrote free-form text to stderr on failure, breaking script consumers that parse the output.sync initalso gains a--format jsonsuccess envelope ({ok: true, created: {configPath, sourceLocale, targetLocales, keys}}) for project-bootstrap scripts. Envelope shape is guarded by an AJV schema and a sharedassertErrorEnvelopetest helper. - sync:
deepl sync resolvenow exits 11 (SyncConflict) when auto-resolution leaves unresolved conflicts, instead of exit 1 (GeneralError). CI pipelines can now distinguish "lockfile needs human merge" from "CLI crashed". Error message includes an actionable hint to edit.deepl-sync.lockmanually and re-rundeepl sync. - sync:
deepl sync --watch --auto-commitnow commits on every successful sync cycle, not only on the initial sync before the watcher attaches. Matches the expected "commit on save" semantics. Gated by the same conditions as the pre-watch auto-commit (clean tree, not dry-run, files written). - sync:
deepl sync --watchno longer leaks SIGINT/SIGTERM listeners across invocations and no longer serves a staletmCacheentry after the TM has been rotated or deleted. The cache now enforces a 5-minute TTL and signal handlers are detached on watcher shutdown; the debounce timer is cleared so "Change detected" cannot print after "Stopping watch". - sync: Stale-lock GC no longer silently deletes lockfile entries when a glob miss is potentially a moved-source rather than a truly-absent file. A broader projectRoot scan by base name guards the deletion; entries that would be GC'd now log a "glob change suspected" warning and are preserved.
- sync: Error messages now sanitize control chars and zero-width codepoints from user-supplied content (YAML keys, key paths, translation text) before rendering, so a malicious config or TMS-returned string cannot corrupt the terminal when shown in a ConfigError or ValidationError.
- sync:
deepl sync pushnow issues push requests with bounded concurrency (default 10, configurable viatms.push_concurrency). Previously pushes ran serially per-key-per-locale, so a 5000-key × 10-locale project took ~hours at typical RTT; the new behavior completes in minutes. Aborts on first failure (unchanged semantic). - sync:
deepl sync resolvenow emits a loud warning whenJSON.parseon a conflict fragment fails and the resolver falls back to a length-heuristic. Previously the heuristic ran silently; users could not audit which entries needed manual review. - sync:
deepl sync initnon-interactive path now validates inputs before writing.deepl-sync.yaml: rejects source locale appearing in target-langs, duplicate targets, empty target-langs, malformed locale codes, and path-traversal. Previously the wizard could write a self-invalidating config that failed at the nextdeepl syncrun with a cryptic error. - sync:
deepl sync --watchnow coalesces file-change events that fire during an in-flight sync; the watcher re-runs once after the current sync completes instead of silently dropping events. Previously rapid edits could leave final changes unsynced until a manual trigger. - sync:
deepl sync --watchnow cleans up.bakfiles on SIGINT/SIGTERM even when a translation is in flight, and sweeps stale.baksiblings at watcher startup (older than 5 minutes). In-flight syncs terminate gracefully after the current locale completes. - sync: Auto-glossary sync now issues a single dictionary-mutation request per locale (previously one per added/removed term) and caches glossary list responses across same-run lookups. Large glossary updates (e.g., 100 term changes) go from 200+ round-trips to ~2 per locale.
- sync:
deepl sync --helpnow groups examples under First-time setup and Everyday use, showing theinit→--dry-run→sync→statusonboarding flow, and adds a pointer todeepl tm listfor translation-memory discoverability. - sync: Acquires an exclusive advisory lock (
.deepl-sync.lock.pidfile) at sync start to prevent two concurrentdeepl syncinvocations from racing the lockfile and losing keys. Stale locks from crashed processes are detected via PID-liveness check and reclaimed with a warning. - sync:
deepl sync --auto-commitnow refuses to commit when the working tree has unrelated modifications, is mid-rebase/mid-merge/mid-cherry-pick, or HEAD is detached. Also runs git commands fromconfig.projectRoot(not the CLI's cwd) and stages only files actually written by the sync run. Previously, auto-commit could bundle a user's in-progress edits into the chore(i18n) commit or fail ambiguously mid-rebase. - sync: TmsClient push/pull now uses a 30s default request timeout (configurable via
tms.timeout_ms), retries 429 and 503 responses with jittered exponential backoff (max 3 attempts), and includes the response body in error messages when available. Previously a stalled TMS server hungdeepl sync push/pullindefinitely and 500-class errors surfaced with no diagnostic context. - sync: Lockfile version-mismatch and JSON-parse recovery now backs up the prior lockfile to
.deepl-sync.lock.bak-<tag>-<timestamp>before resetting in-memory state. Previously a corrupt or wrong-version lockfile was silently discarded, forcing full retranslation with no recovery path. - sync: Invalid
.deepl-sync.yamlnow exits 7 (ConfigError) instead of 6 (ValidationError), matching the documented exit-code contract in docs/SYNC.md and docs/TROUBLESHOOTING.md. - sync:
deepl sync initnow exits 6 (ValidationError) immediately when stdin is not a TTY and fewer than all four init flags are supplied. Previously the partial-flag path fell through to@inquirer/promptsand either threwExitPromptErroror blocked indefinitely in CI. - sync:
deepl sync --frozen --watchnow exits with ValidationError (code 6). Previously the combination was documented as invalid but entered an infinite drift-check watch loop. - sync: Every
ConfigErrorthrown fromvalidateSyncConfig(.deepl-sync.yamlvalidation) now includes a remediationsuggestionstring pointing the user at the exact YAML field to fix. Previously ~15 of 18 throw sites provided only a title, defeating the advertisedDeepLCLIError.suggestionconsumer contract. - sync:
deepl sync pullnow fetches each target locale's dictionary once per sync instead of once per (source file x locale) pair. Previously a repo with N source files and L target locales issued N x L identical GETs to the TMS; the new behavior issues L. Affects push/pull throughput on multi-bucket or multi-file projects. - api:
listTranslationMemoriesnow paginates theGET /v3/translation_memoriesresponse using the documentedpage/page_sizequery parameters (max 25 per page, bounded at 20 pages). Accounts with more than 25 translation memories previously received a silently truncated list, which causeddeepl tm listand the TM name → UUID resolver to miss entries. The first call is still issued without query params for backward compatibility and only continues when the server'stotal_countindicates more pages are available. - sync:
deepl sync --format jsonnow emits{error, code}JSON to stderr on failure (matching thesync status --format jsonerror contract) and exits with the correct granular exit code. Previously the top-level command fell through to free-form stderr regardless of--format. - sync:
deepl sync --format json(andstatus,validate,audit) now emit the success JSON payload on stdout, not stderr. Previouslydeepl sync --format json > out.jsonproduced an empty file because the payload was interleaved with progress logs on stderr. - translate:
deepl translate file.txt --to en,fr,es --glossary <name>and--translation-memory <name-or-uuid>were silently dropped on the multi-target code path, so terminology and TM were not enforced when translating to more than one language. The multi-target branch now mirrors the single-target precondition and resolution shape:--fromis required, TM rejects non-quality_optimizedmodel types, glossary and TM are resolved once per invocation, andmodelTypedefaults toquality_optimizedwhen TM is set. - translate: Translation memory resolver cache now keys entries by
name|from|targets, so the pair-check runs every time a different pair is requested under the same TM name within a session. Previously, sync configs with no top-leveltranslation_memorybutlocale_overridessharing a TM name across locales with mismatched pair support could silently reuse an incompatible TM UUID on the second locale. - translate:
warnIgnoredOptionsnow actually fires for--translation-memoryand--tm-thresholdin modes that do not support them (e.g.directory,document). The keys were present in the handler supported-sets but missing fromoptionLabels, so the warning was inert. - translate: Harden TM name resolution against API-returned name pollution.
resolveTranslationMemoryIdnow filters entries whose names contain ASCII control chars or zero-width codepoints before matching, and throwsConfigErrorwhen two entries share the exact name a caller is resolving (asks for UUID disambiguation instead of first-create-wins). Closes a theoretical collision vector against server-side tenancy. - glossary: Glossary resolver hardening —
resolveGlossaryIdnow filters API-returned glossary entries whose names contain ASCII control chars or zero-width codepoints before name matching, and throwsConfigErrorwith a UUID-disambiguation hint when two surviving entries share the same name. Mirrors the TM resolver defenses. - examples:
examples/31-sync-ci.shpasses--file-format jsontodeepl sync init(was--format json, which is not a registered flag oninitand would fall through to the interactive-prompt branch in non-TTY environments). - api:
listGlossariesandlistTranslationMemorieserrors now carry their method name as a[listGlossaries]/[listTranslationMemories]suffix onerror.message. Suffix (not prefix) preservesdeepl sync --format jsonstderr-shim consumer greps on canonical phrases likeAuthentication failed: Invalid API key. - sync: Reject
translation_memorypaired with a non-quality_optimizedmodel_typeat config load (ConfigError, exit 7) instead of letting the DeepL API reject each translate request. Applies at top-level and per-locale override. - sync: ICU MessageFormat preservation — plural, select, and selectordinal structures are now preserved during translation. Only leaf text is sent to the API; structural keywords (
plural,one,other, etc.) and variable names are kept intact. Handles nested ICU (e.g., select inside plural). - sync: Progress output no longer shows
0/0 keyslines for up-to-date locales. - sync: New-locale translations now correctly count in progress output.
- sync:
sync resolveconflict marker detection now works mid-file (added multiline flag to regex) - sync:
sync validate,sync status,sync export,sync push, andsync pullnow handle multi-locale formats (.xcstrings) correctly - sync:
sync initauto-detection now generates valid glob patterns instead of{locale}placeholders that fast-glob cannot match - sync:
resolveTargetPathsupportstarget_path_patternfor Android XML and XLIFF where source locale is absent from source path - json: Warn when JSON files contain duplicate keys (last value used per RFC 8259)
- sync: Validation now detects untranslated content (translation identical to source) and excessive length ratio (>150%)
- sync: Config validator now passes through
translation,validation,sync,tms, andignoreYAML blocks - sync: CLI overrides (
--formality,--glossary,--model-type,--context) now merge into config - sync:
--forcemode no longer causes index misalignment when lock has deleted keys - sync: Failed translations now recorded as 'failed' (not 'translated') in lock file per locale
- sync:
--frozenmode now detects drift for deletion-only changes - sync: Lock file structural validation prevents crash on malformed lock files
- sync: Batch translation context correctly scoped — per-key requests when context is available, batch without context otherwise
- sync: Reject path traversal in target locale at config validation
- sync:
sync export --outputnow rejects paths that escape the project root and creates missing intermediate directories before writing - sync:
deepl sync auditnow surfaces real translated text read from target files instead of SHA hashes, so terminology-inconsistency output is readable. Missing target files fall back to the hash so divergence is still detected. - sync: Restore
--localeand--formatoptions on the baredeepl synccommand (previously dropped during an earlier commander option-shadowing fix) and wire--sync-configend-to-end — commander camelCases the flag tosyncConfig, but the handler was readingconfig, so the flag was silently ignored. - sync: Protect placeholders from translation via preserveVariables
- sync: PO format reconstruct preserves translations on re-sync
- sync: Validate source_locale for path traversal characters
- sync: Add
assertPathWithinRootguard in sync validate - sync: Fix
resolveTargetPath$nlocale injection via function callbacks - sync: Skip deleted diffs in sync-status coverage counts
- sync: Validate HTTPS scheme in TmsClient
- sync: Replace blocking
readFileSyncwith async read in context extraction - po: Correct escape sequence order in
unquote()-- backslash processed first - po: Multi-line PO header no longer deleted during reconstruct
- po: Use ASCII EOT separator for msgctxt keys (fixes
#in msgid collision) - formats: Prevent
$-pattern corruption in XLIFF, iOS Strings, and text-preservationString.replacecalls - android-xml: Add backslash escaping and preserve extra attributes on plurals/string-arrays
- json: Handle UTF-8 BOM in JSON files
- yaml: Handle empty content in YAML reconstruct
- utils: Use unique temp filenames in atomicWriteFile to prevent concurrent corruption
- sync: Pre-initialize localeSuccessMap to prevent concurrent locale overwrite race
- sync: Retry failed lock entries — computeDiff checks translation status
- sync: Resolve per-locale glossary override by name instead of passing raw string
- sync: Guard force+frozen combination in sync() API
- sync: Only write pull lock file when entries were actually processed
- android-xml: Correct unescapeAndroid escape order using single-pass regex.
- formats: Reconstructed output for Android XML, YAML, iOS Strings, XLIFF, and ARB parsers now omits keys that were deleted from the source. ARB also inserts newly-added keys at reconstruct time.
- sync: Unified placeholder regex with frequency-based comparison
- sync: Support Unicode placeholder names and positional printf specifiers (%1$s)
- po: Remove fuzzy flag when providing fresh translation
- sync: Validate optional config block types (translation, validation, sync, tms) before casting
- sync: Normalize documented bucket keys across
sync,status,validate,push,pull, andsync init - sync: New locale detection — translate existing keys when a target locale is added
- sync: Write lock entries for new-locale translations to prevent re-translation
- sync:
--frozennow detects drift when a new target locale is added - sync:
--dry-runreports pending new-locale translation in key counts - sync: Clean stale lock entries for files no longer matched by any bucket glob
- sync: Merge
config.ignorepatterns into fast-glob for status, validate, push, pull - sync: Guard
source_locale==target_localein config validation - sync: Deep-clone diff metadata per locale to prevent concurrent mutation
- sync: Wrap locale worker in try/catch for graceful per-locale error handling
- sync: New-locale translation path applies
preserveVariables/restorePlaceholders - sync: PO plural forms for 3+ form languages (Russian, Arabic) fill higher msgstr indices
- sync:
--frozenguards stale lock entry cleanup and lock file write - sync: Preserve translateBatch index alignment by returning sparse array on partial failure
- sync:
restorePlaceholdersreplaces all occurrences (not just first) - sync: Fix
context_linesdefault to 3 (matching documentation) - android-xml: Escape
<,>,&in translations to prevent XML injection - json: Guard against 0-byte source files
- translate: Invalid
--toerror is now concise — the 100+ language-code dump is removed; the message points atdeepl languagesfor the full list. - examples:
examples/30-sync-basic.shandexamples/31-sync-ci.shnow clean up/tmp/deepl-sync-demo/and/tmp/deepl-sync-ci-demo/on mid-script failure viatrap cleanup EXIT(matching the pattern already in examples 32 and 34). - docs:
docs/API.mdanddocs/SYNC.mdnow document the--format FORMAToption ondeepl sync export(previously undocumented even though the flag was registered insrc/cli/commands/sync/register-sync-export.ts). Clarified that onsync exportthe format choice affects only the error envelope on stderr; the success output is always XLIFF 1.2. - docs:
docs/API.mdcorrected the note on theauditsubcommand rename — the previous wording said "Prior to the 1.0.0 release, this subcommand was namedglossary-report", which implied 1.0.0 users had access to it. The prototype nameglossary-reportnever shipped in any tagged release; now worded consistently with the 1.1.0 CHANGELOG entry. - write:
deepl write --interactivenow fails fast with aValidationErrorwhen stdin is not a TTY (e.g., a CI job that passes--interactivewithout--no-input). Previously the process would hang indefinitely on an@inquirer/promptsselectcall that a non-TTY stream can never answer. - translate:
deepl translate --format tablenow falls back to plain[lang] textoutput with aWARNline on stderr when stdout is not a TTY. Screen readers and log scrapers no longer have to parsecli-table3's Unicode box-drawing characters; pipe--format table > out.txtproduces parseable plain text instead of Unicode noise. - output: Spinners (
ora) are now gated onprocess.stderr.isTTYat theLogger.shouldShowSpinner()chokepoint. CI and piped-stderr contexts no longer risk ANSI escape leaks into log files regardless of which callsite instantiates the spinner. - color:
NO_COLORis now explicitly honored in the CLI bootstrap by settingchalk.level = 0when the env var is present.chalkalready auto-detectsNO_COLOR, but the explicit hook keeps the two color-detection paths in the codebase (chalkandisColorEnabled()inutils/formatters.ts) unambiguously in sync ifchalk's auto-detection ever changes or is mocked in tests. - sync init: a bare
process.exit(7)literal inregister-sync-init.ts's JSON-output path now goes throughExitCode.ConfigErrorso future exit-code renumbering can't desync the hard-coded value from the rest of the codebase. - xliff: the chained-
.replace()XML entity decoder double-decoded entities.&lt;(the literal 5-character string "<") was silently collapsed to "<" because the decoder ran&→&on the first pass and then<→<on the second. Replaced with a single-pass regex that handles the five named entities plus decimal (&#NN;) and hex (&#xNN;) numeric character references, retiring both bugs in one stroke. Round-tripping literal entities through translation now preserves them byte-for-byte. - xliff: CDATA sections inside
<source>/<target>elements were silently malformed on round-trip —</>bytes inside a CDATA body round-tripped asymmetrically through the escape pass. The parser now rejects CDATA inside translatable elements with aValidationErrorat extract time, matching the allowlist posture of the Laravel PHP parser's heredoc / interpolation rejection. CDATA in non-translatable positions (e.g.,<note>) is still accepted.
deepl sync --forcebilling defense:--watch --forceis now rejected at startup withValidationError(exit 6) — the combination would silently retranslate every key on every file change, creating an unbounded billing loop. Additionally,--forcenow requires confirmation in interactive mode ("Retranslate all keys and bypass cost cap? [y/N]"); add--yes(-y) to skip the prompt in scripts. In CI environments (CI=true),--forcerequires--yesexplicitly — the process exits 6 with an actionable hint rather than proceeding silently.- Updated
minimatchfrom^9.0.5to^10.2.1to fix ReDoS vulnerability (GHSA-3ppc-4f35-3m26) - sync:
deepl sync pullnow acquires the pidfile process lock (acquireSyncProcessLock) before writing any target files. Previously, a concurrentdeepl sync(which holds the lock while writing target files) anddeepl sync pullcould race across multiple files —atomicWriteFileprevents torn individual writes but the multi-file read-modify-write cycle was unguarded.deepl sync pushis read-only toward local files and does not need the guard. - sync:
sanitizePullKeysResponsenow enforces a hard cap of 50,000 keys (MAX_PULL_KEY_COUNT) on TMS pull responses. A response exceeding this limit is rejected with aValidationErrorbefore any data is written to disk, preventing OOM conditions on large TMS inventories (e.g. 100K keys × 20 locales). Remediation: partition the TMS export by locale or paginate the pull. - sync: TMS error responses are now sanitized through
sanitizeForTerminalbefore appearing in thrownErrormessages. Both the response body (capped at 1024 bytes) andresponse.statusTextare stripped of ANSI escape sequences, bidi override codepoints (U+202A–U+202F), and other terminal-unsafe control characters. Previously a malicious or compromised TMS server could inject sequences such as\x1b[2J(screen clear) or U+202E (right-to-left override) into the operator's terminal via a crafted 4xx/5xx HTTP response. - sync: TMS
sync pullnow validates response payloads at the TmsClient boundary before writing to source trees. Non-string values, keys with path separators or control chars, and values >64KiB are rejected with ValidationError; control chars are stripped from accepted values. Previously a compromised or misconfigured TMS could write arbitrary bytes — including format-breaking XML or terminal-corrupting control sequences — into the user's working tree. - security: Validate
context.scan_pathsagainst project root with symlink protection - security: Use URL hostname check for
tms.server(preventslocalhost.evil.combypass) - security: Encode
tms.project_idin URL path - sync:
sync push,sync pull,sync export, andsync validatenow refuse to follow symbolic links when scanning source files, matching the policy already enforced bysyncitself andsync-context. Previously a symlink inside a bucket'sincludepattern would be silently followed, potentially reading and transmitting files outside the project root (e.g.,/etc/passwd, SSH keys). - sync:
Logger.sanitize()now redacts TMS credentials (TMS_API_KEY,TMS_TOKENenv values, andAuthorization: ApiKey/Bearer <value>headers). Previously onlyDEEPL_API_KEYandDeepL-Auth-Keywere covered, so TMS credentials could leak into logs via error messages, Headers dumps, or stringified fetch error bodies. - sync: Harden
sync resolveconflict-fragment merge against prototype pollution. JSON-parsed fragments can carry__proto__/constructor/prototypeas own properties; the merge now skips those keys and usesObject.create(null)accumulators sodeepl sync resolveon a hostile.deepl-sync.lockcannot mutateObject.prototype. - sync:
deepl sync exportnow rejects source-side paths that resolve outside the project root with a ValidationError, matching the--outputdestination guard. Previously a.deepl-sync.yamlwith absolute source paths or symlinks pre-dating the fast-glob hardening could read files outside the configured scan root during export. - sync:
scan_pathsconfig entries are validated against path traversal using a proper glob-literal prefix extractor rather than a regex strip. Previously crafted patterns using brace-expansion ({..,src}/**), extglobs (@(..)/**), or escaped wildcards could bypass the priorassertPathWithinRootguard. No change to valid configurations; rejected configurations now produce a ConfigError with the offending pattern shown. - deps:
npm audit fix— resolvesaxiosGHSA-3p68-rc4w-qgx5 (SSRF via NO_PROXY normalization),axiosGHSA-fvcv-3m26-pcqx (cloud-metadata exfil via header injection), andfollow-redirectsGHSA-r4q5-vmmm-2653 (auth-header leak on redirect). Not reachable from the CLI (baseUrl hardcoded toapi.deepl.com; TMS uses nativefetch); transitive advisories are now quiet. - sync:
.deepl-sync.yamland auto-detect-path reads (package.json, the first-match i18n file for key counting) now route throughsafeReadFileSync, which rejects symbolic links with aValidationError. A hostile repo could previously ship a.deepl-sync.yamlsymlinked to~/.ssh/id_rsa(or another dotfile outside the project root) and surface the target's contents in YAML parser errors on stderr. Affected sites:src/sync/sync-config.ts:566,src/sync/sync-init.ts:239,src/sync/sync-init.ts:271. Runtime file reads duringdeepl syncitself are unchanged — the bucket walker already refuses symlinks viafast-glob'sfollowSymbolicLinks: false. - http: When
HTTP_PROXY/HTTPS_PROXYis configured with anhttp://proxy and the target endpoint ishttps://, the CLI now emits a startup warning naming the proxy host and noting the TLS-termination MITM risk. TLS is still tunneled end-to-end via CONNECT, so this is a visibility fix rather than a behavior change — a malicious proxy that terminates TLS with a trusted cert would see the Authorization header, and the user should be aware. Users with legitimate corporate http-only proxies see the warning but the connection proceeds (no forced abort; the CLI can't tell corporate infra apart from attacker infra). - log redaction:
Logger's sanitizer now redactsX-Api-KeyandX-Auth-Tokenheaders, plus?api_key=/?apikey=query parameters. Previously onlyDeepL-Auth-Key,Authorization: ApiKey|Bearer,?token=/&token=, and theDEEPL_API_KEY/TMS_API_KEY/TMS_TOKENenv-var exact values were covered. axios error dumps that includeconfig.headerson TMS-style third-party backends (e.g., Phrase, Lokalise, custom REST endpoints) no longer leak these credentials via verbose logs.
- Text translation via DeepL's next-generation LLM (
deepl translate) - Document translation for PDF, DOCX, PPTX, XLSX, HTML, SRT, XLIFF, and images with formatting preservation
- Structured file translation for JSON and YAML i18n locale files (keys, nesting, comments preserved)
- Writing enhancement with grammar, style, and tone suggestions (
deepl write) via the DeepL Write API - Real-time speech translation via WebSocket streaming (
deepl voice) with automatic reconnection - Watch mode for real-time file monitoring with auto-translation (
deepl watch) - Batch directory translation with parallel processing, glob filtering, and concurrency control
- Glossary management with full v3 API support including multilingual glossaries (
deepl glossary) - Language detection (
deepl detect) - Git hooks for pre-commit, pre-push, commit-msg, and post-commit translation workflows (
deepl hooks) - Interactive setup wizard (
deepl init) - Admin API for key management and organization usage analytics (
deepl admin) - Shell completion for bash, zsh, and fish (
deepl completion) - SQLite-based translation cache with LRU eviction and configurable TTL
- Custom translation instructions (
--custom-instruction) and style rules (--style-id) - XDG Base Directory Specification support with legacy path migration
- JSON output format (
--format json) across all commands for CI/CD scripting - Table output format (
--format table) for structured comparison views - Semantic exit codes (0–9) for CI/CD integration and scripted error handling
- HTTP/HTTPS proxy support via standard environment variables
- Automatic retry with exponential backoff and
Retry-Afterheader support - Dry-run mode (
--dry-run) for previewing destructive and batch operations - Cost transparency with
--show-billed-charactersflag - Multi-target translation to multiple languages in a single command
- Context-aware translation (
--context) for disambiguation - Model type selection (
--model-type) for quality vs. latency trade-offs - Advanced XML/HTML tag handling with splitting, non-splitting, and ignore tags
- HTTPS enforcement for all API communication (localhost exempted for testing)
- Symlink rejection on all file-reading paths to prevent directory traversal
- API key masking in logs, config output, and error messages
- Config file permissions restricted to owner read/write (0o600)
- Path traversal defense for batch output patterns
- Atomic writes for translated output and config files to prevent corruption
- Requires Node.js >= 20