Skip to content

Latest commit

 

History

History
658 lines (614 loc) · 240 KB

File metadata and controls

658 lines (614 loc) · 240 KB

Changelog

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.

[Unreleased]

[2.0.0] - 2026-08-10

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.

Added

  • cli: deepl correct (alias c) — spelling and grammar correction without rewording, via the Write API's /v2/write/correct endpoint. Supports the same input handling and workflow flags as write (--check with 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 separate correct: namespace.
  • translate: --glossary is repeatable, applying up to 5 glossaries to one request via the API's glossary_ids parameter. 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 --glossary still goes out as glossary_id, and a 6th exits 6 (ValidationError) before any API call. watch and sync keep their single-glossary configuration.
  • translate: --glossary now 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. --from is required, since the API rejects a document glossary without a source language, and --translation-memory remains unsupported for documents. Glossary matching is context-dependent for documents exactly as it is for text.
  • languages: deepl languages --features shows which features each language supports — formality, glossary, style rules, translation memory, tag handling and auto-detection — from the features matrix on GET /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 as no feature data rather 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 --features is 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, --diff and --alternatives honour --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: true discriminates a result from the ok: false error envelope, file is the absolute path and present only for file input, and --check deliberately omits original/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 absent ok, and exit codes are unchanged in both modes.
  • sync: deepl sync pull --dry-run previews 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, --verbose names each key and file whose TMS value differs from the local one, and --format json carries replaced and dryRun alongside pulled. Also accepted on the parent (deepl sync --dry-run pull).
  • sync: --break-lock on deepl sync, deepl sync pull and deepl sync resolve takes the process lock even when .deepl-sync.lock.pidfile names 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 --watch session 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 json output now includes the documented cached boolean, so scripts can distinguish cache hits from fresh API calls.
  • http: NO_PROXY / no_proxy are honoured with the standard semantics — * for everything, a leading dot or *. for subdomains, and an optional host:port that must agree — so a corporate HTTPS_PROXY is no longer applied to a request aimed at localhost.
  • auth: deepl auth set-key --no-verify stores 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-key and init) failed and discarded the key; an unreachable API now also names DEEPL_API_KEY as the zero-network alternative.
  • cli: t and w command aliases for translate and write (#12), shown in --help output and in bash/zsh/fish completions. w is deliberately assigned to write rather than watch.
  • 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 against package.json first, 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 artifact job packs the tarball, installs it globally to run deepl --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 and bin has its own module graph, so a broken programmatic entry point could otherwise ship while every test passed. The test matrix also pins 24.15.0 alongside 24, so the engines.node floor is exercised rather than only the latest 24.x.
  • ci: npm run check-deps fails the build when a package imported by src/ is missing from dependencies, including one declared only under devDependencies. It runs in CI and in the publish job, and matches package names as quoted strings so indirect loads such as requireModule('php-parser') count as references.

Changed

  • BREAKING — package: The package is now published as the scoped @deepl/cli (previously the unpublished working name deepl-cli), with publishConfig.access: "public" set explicitly. The bin name is unchanged — the command is still deepl — so scoping changes only the install string (npm install -g @deepl/cli). Repository, bugs and homepage metadata now point at github.com/DeepL/deepl-cli directly.
  • BREAKING — cache/runtime: The translation cache now uses Node's built-in node:sqlite instead of the better-sqlite3 native addon, and the CLI consequently requires Node.js >= 24.15.0 (engines.node is now >=24.15.0) — 24.15.0, not 24.0.0, is the release where node:sqlite stopped emitting ExperimentalWarning: 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 json now 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 every sync subcommand at once, from one shared writer. > out.json now captures both the success payload and the failure envelope, and a human reading --format json output sees the envelope's message/suggestion fields where a prose sentence used to be. config get/config list default to json, 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 show now reports Source language: en and en → es: 5 entries, tm list renders brand-terms (en → de, fr), translate --format table labels rows de, and write --format json reports "language": "en-us" — so scripts scraping these values see a casing change. Input is case-insensitive everywhere, so no command line has to change; voice included, 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/correct also send the lowercase code as target_lang, which the Write API canonicalizes server-side. Wire parameters that are not display output are untouched: translate and the glossary create endpoint still send uppercase source_lang/target_lang.
  • BREAKING — sync: deepl sync now 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 --frozen exits 10 and sync status counts such keys against the locale; validation.fail_on_error: true still promotes a validation error to exit 6, and SyncResult.success is false for these runs. deepl sync pull reports an unreadable target as a new unusable_target skip reason at its existing exit code.
  • BREAKING — sync: deepl sync validate exits 8 where it exited 1 on a project whose target file cannot be read — reported in --format json under a new unusable_target check kind, with key and file both the target path and empty source/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 real msgstr / <target> instead of comparing the source against itself. An untranslated entry (empty msgstr, no <target>) is not a source/translation pair and is still not validated at all.
  • BREAKING — sync: deepl sync push / deepl sync pull exit 5 (the documented retriable code) where they exited 1 when the TMS could not be reached. Request counts are unchanged.
  • BREAKING — sync: deepl sync --force without --yes now exits 6 wherever it cannot prompt — piped or closed stdin, a git hook, a cron job, a make target, a container entrypoint, --no-input — not just under CI=true. See Security for why it was doing the opposite.
  • BREAKING — watch: A deepl watch session now exits 12 rather than 0 when it recorded any translation error or any --auto-commit failure, and the auto-commit failure count is printed beside the translation total; a session with no failures still exits 0. deepl watch --auto-commit also 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 watch writes a nested source file to a nested output path: watching docs/ with --output out, docs/guide/intro.md now lands at out/guide/intro.es.md where it used to land at out/intro.es.md, matching what deepl translate <dir> --output <dir> has always produced. Anything reading a session's output by flat basename — a publish step, a .gitignore entry, an --auto-commit diff — 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 translate exits 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-handling now pins tag_handling_version=v2 instead of letting the API pick, so --tag-handling xml/html output may differ from previous releases; pass --tag-handling-version v1 to keep the old behaviour, which always wins over the default. Requests without --tag-handling send 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, and deepl sync exits 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 json can 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 --format at its default. Text mode is unchanged.
  • BREAKING — hooks: deepl hooks list --format json reports 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 with state === "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, and GitHooksService.list() returns those states while isInstalled() is unchanged. See Security.
  • BREAKING — types: WriteLanguage members are lowercase — 'en-gb', 'en-us', 'pt-br', 'pt-pt', 'zh-hans' — so a literal in the old casing no longer compiles, and WriteImprovement.targetLanguage widens from WriteLanguage to string because the API echoes that field in its own casing. SyncTmsConfig drops the three removed tms keys, so a consumer setting them stops compiling rather than being ignored at runtime. See docs/MIGRATION.md.
  • sync: --format json gains skip reasons and fields. sync pull can report shared_target (a target another sync configuration's lockfile accounts for), plural_entry (one exported string cannot fill a plural entry's forms), unusable_target and key_collision; sync push can report untranslated (a PO or XLIFF key not yet translated, previously uploaded as its own source text) and needs_review. All of these also appear in the (N skipped: …) summary line, are excluded from pulled/replaced, and get no lockfile entry. sync pull --format json gains replaced and dryRun, its text output gains a line naming a non-zero replaced count, and each locale in sync status --format json gains a needsReview count.
  • sync: Pulled keys no longer carry review_status in .deepl-sync.lock at all, so anything reading review_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 status reports lower coverage for PO and XLIFF projects. A #, fuzzy PO entry and an XLIFF review state now count as needing review rather than complete, so a project reported at 100% drops to the share actually shippable — which is what msgfmt has reported all along — and sync push reports a correspondingly lower pushed count. deepl sync also writes state="translated" on an XLIFF target whose translation it replaced, where it used to leave the old value; a target that carried no state is written exactly as before. Nothing is re-translated or re-billed and sync --frozen still passes. The needsReview explanation no longer describes gettext alone to users of a format that has no #, fuzzy.
  • sync: A project that sets sync.max_characters can be refused where it previously ran, and deepl sync --dry-run can 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-run now reports. --dry-run still writes nothing and still exits 0.
  • sync: Backups are written as <file>.deepl.bak instead of <file>.bak, and the stale-backup sweep considers only the .deepl.bak suffix, so a user's own *.bak files 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: .bak files from earlier versions are no longer swept or restored; delete leftover <target>.bak files 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, sync and language values in the config file, while input that is not shaped like a language tag still fails fast with a pointer to deepl languages. The listing is API-driven: deepl languages renders the union of the API response and the bundled list. The list is generated: npm run generate:languages rewrites it from GET /v3/languages and npm run check:languages fails 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 WriteLanguage type 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/correct still 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 Language union 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). ENTRIES is generated as const satisfies readonly LanguageEntry[] and the union derives from its codes, exactly as WriteLanguage derives from WRITE_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 now readonly, 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 languages already 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/languages and GET /v2/glossary-language-pairs to GET /v3/languages (resource=translate_text / resource=glossary / resource=write). Command output is unchanged: source/target lists derive from the v3 usable_as_source/usable_as_target flags, glossary pairs from the source×target cross-product (verified identical to the v2 pair list), and the [F] formality markers from the per-language features matrix. 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 init and auth show print to stdout, so deepl sync status > report.txt and deepl auth show > key.txt capture output instead of producing empty files. Diagnostics, warnings and progress stay on stderr, and --format json stdout purity is unchanged.
  • glossary: create and show render the creation timestamp as a locale-independent ISO string, and the create success 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.yaml present and the CLI on PATH it runs deepl sync validate and blocks the commit on validation errors, with a --no-verify hint. 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-format choices 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/deleteIn per 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: commander 14.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 .nvmrc pins Node 24 to match — it still read 20, so nvm use handed developers a runtime that cannot load node:sqlite.
  • build: npm run build runs a clean step first, removing dist/ and tsconfig.tsbuildinfo before compiling, so a file rename can no longer ship stale artifacts in npm pack output.
  • 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 the better-sqlite3 native-compilation caveat. Install strings are updated across docs/SYNC.md, four example scripts, examples/README.md and the git-hook template in src/services/git-hooks.ts. CONTRIBUTING.md states the Node 24 development prerequisite, SECURITY.md's supported-versions table reflects that only 2.x is a published line, and six stale DeepLcom GitHub URLs now point at the DeepL org.
  • 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_RUNS overrides; FC_SEED/FC_PATH replay a recorded counterexample). It found the TOML U+2028 corruption and the .properties leading-space loss fixed in this release.

Removed

  • BREAKING — sync: tms.auto_push, tms.auto_pull and tms.require_review are gone from the config schema. All three were on the tms: allowlist and in docs/SYNC.md, and no code read any of them, so a review gate configured through require_review was doing nothing. Each now fails config load with a ConfigError (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_review is not implementable from this side, since the documented export contract is a flat { key: value } map with no per-entry review flag; use deepl sync pull --dry-run to preview a pull instead, and run deepl sync push / deepl sync pull explicitly in place of the auto flags.
  • BREAKING — cli: The --enable-beta-languages flag on translate is 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-lang and --target-langs, the deprecated aliases introduced in 1.x, are removed and fail with error: unknown option (exit 6). Use --source-locale and --target-locales. deepl translate --target-lang is 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 of speech_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-key speech_to_text_milliseconds usage limit is a different, still-current field and is unaffected.
  • deps: better-sqlite3 and @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_VERSION after 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 whose node:sqlite is unusable warns once and runs uncached rather than crashing, and never touches the cache database.
  • deps: inquirer, which no source file imported.
  • repo: The VERSION file — nothing read it, since deepl --version reports package.json's value, so it was a second hand-edited source of truth that could only drift — and .npmignore, which was dead weight because the files array 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 sourceMappingURL comments in the shipped files, giving consumers unresolvable stack frames and broken go-to-definition.

Fixed

  • sync: Two concurrent deepl sync runs 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 recorded pid and startedAt to 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 fish no 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_type is now applied. The key was on the config allowlist and validated per locale against translation_memory, but the translator only ever read the top-level translation.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. SyncLocaleOverrides also declares the field, and — as with those siblings — a per-locale override now takes precedence over --model-type.

  • sync: A per-locale translation_memory that inherits a non-quality_optimized top-level model_type is now rejected at config load (exit 7) instead of being accepted and sent to the API. The pairing check only looked at a model_type written 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 sync over an already-translated project no longer overwrites every reviewer translation with machine output. The carry-forward that protects a reviewed translation was wired into the current-key path only, so with no .deepl-sync.lock — first adoption, or any CI checkout where the lockfile is gitignored — every key arrived as new and was re-translated, re-billed and written over, dropping #, fuzzy and XLIFF state markers at exit 0. A new key 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 --force still 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, including sync push and sync validate. Parsers may now override the target-side read (FormatParser.extractTranslations), PO and XLIFF do, and all six sites go through one helper. An empty msgstr, 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 pull had the same blind spot in its merge base and deepl sync audit measured consistency across source strings rather than translations.

  • sync: deepl sync push no longer uploads the source language as a locale's translation — a locales/es/app.po holding msgstr "Hola amigo" used to push the English msgid, making the TMS authoritative for the wrong text. TmsClient.pushEntry now takes the translation as a required argument rather than reading it off entry.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. ENOENT is 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 key failed for the next run to retry. deepl sync pull routes every unreadable target to the same outcome under a new unusable_target skip reason; deepl sync push already propagated everything but ENOENT. 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 sync and deepl sync pull no 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 sync fails that locale (nothing written, nothing billed, keys recorded failed, exit 12) and deepl sync pull skips it under shared_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 called en.json; omit target_path_pattern or 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. translateBatch chunks at 50 texts and keeps going when one chunk fails, and those empty slots used to fall through to a bare failed++ — so, because reconstruct treats the entry list as the complete desired key set, the failed chunk's keys were removed from the file (and the run's .bak unlinked, since the run itself succeeded). A key the target already holds now keeps its translation, and is deliberately recorded failed rather 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 reconstruct template, so a newly added key has no slot in it — properties, ios_strings, laravel_php, android_xml and xliff discarded it while the lockfile recorded it translated, 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 in android_xml, and a laravel_php key whose parent array is absent from the target — both now recorded failed rather than translated.

  • 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, and sync 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. See docs/SYNC.md "A plural entry carried forward".

  • sync: A #, fuzzy flag on a carried-forward PO entry survives a run that rewrites the file for a sibling key. The comment replay stripped fuzzy from every entry it emitted, so any sync with other work to do removed a reviewer's "do not ship" marker and sync status then reported 100% again. The flag is now stripped only when the run writes different content over the entry — comparing the msgstr and every msgstr[N] — and a carried entry keeps its comment lines byte for byte (a #, fuzzy, python-format line is no longer re-joined).

  • po: A wrapped (continuation-line) plural form survives a run that only rewrites its entry for a sibling key. reconstruct re-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 loss msgfmt --statistics does 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 recorded status: "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, and validation.fail_on_error keeps its false default 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 status reporting them missing while deepl sync refused 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 translated status, 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 --frozen and sync itself 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 recorded translated against the current source, the locale's target file is now read; a key it does not hold is counted unwritten, its own category distinct from missing (absent from the lockfile) and outdated (recorded against an older source) and never counted as complete. sync status names the file and key, --frozen exits 10 naming the count, and sync translates the key again and writes it. New JSON fields: unwritten per locale and top-level unwrittenByLocale on sync status, and unwrittenKeys on the sync result. Two deliberate exemptions: a key whose source value is empty, and a key already recorded failed or against an older hash. The read is skipped for a locale the lockfile claims nothing for.

  • sync: sync status no 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 a status that said to run sync and a sync that 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 json carries the reason as a new unusable field on the locale's unwrittenByLocale entry. The --frozen drift line no longer asserts a cause it has not checked and points at sync status for the detail.

  • sync: --dry-run previews the run rather than the lockfile, in both directions it was wrong: it under-quoted repair work (reporting estimatedCharacters: 0 for 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 one findTargetGaps call per source file: gap keys are added to the estimate and reported as unwrittenKeys, 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_characters cost 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 set max_characters are unaffected.

  • sync: --frozen no 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 on result.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 keys line above it. The count totals every locale and nothing is appended when nothing failed. The progress stream's key-translated events also fire only when that key's translateBatch slot came back non-null, so the number it reports agrees with the summary.

  • sync: Staleness is judged per target locale. computeDiff compared only the entry-level source_hash, so once one locale was re-synced every other locale reported current forever and --frozen could not see it; and one locale's failure marked the key stale for all locales, so deepl sync --locale de re-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 status distinguishes "the source changed" from "this locale's record lags" by re-checking the source hash and counts a failed translation as missing rather than outdated, and locales absent from target_locales are ignored while a locale with no entry at all is still treated as a new-locale backfill.

  • sync: A gettext #, fuzzy entry and an XLIFF review state are no longer counted as finished translations — msgfmt leaves 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 own needsReview category in sync status (text suffix , 1 needs review plus a line explaining the marker and both ways out; needsReview per locale in --format json), and deepl sync push skips it under a new needs_review skip reason instead of uploading a draft as approved. Nothing is re-translated, rewritten or re-billed: removing the marker returns the key to complete with 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's new and needs-* and 2.0's initial count, translated/signed-off/final/reviewed do not, and an absent or unrecognised value counts as complete, so an existing project's coverage does not move. state-qualifier (1.2) and subState (2.0) are not read, and sync --frozen deliberately still passes.

  • sync: A state attribute 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 becomes translated when reconstruct replaces the target's content, a signed-off state written over is downgraded the same way rather than claiming human approval for machine output, an element carrying no state still gains none, and a state on a unit whose translation is unchanged is never touched — which is what keeps a reviewer's needs-review-translation alive 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 one readdir); 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, and bak_sweep_max_age_seconds keeps its meaning for redundant backups. The fix is in sweepStaleBackups, 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 uses COPYFILE_EXCL, and on EEXIST the 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 one setImmediate. 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 — WatchController only 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.bak deleted, 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 raw EEXIST, the retry loop is bounded at 5 attempts, and the payload is written to a private path and link(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 recorded startedAt was 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 resolve takes the process lock, --dry-run included, and exits 7 with the same "Another deepl sync process 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 merge can 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_reviewed from one side carrying the other's translated_at, marking machine output as human-approved at exit 0 with no conflict reported — and made sync resolve's translated_at tie-break unreachable, so every field took the kept ours: scalar conflict path and discarded newer human translations. A region's trailing comma is now removed before parsing and restored after, keys are sorted within the line, stats is written on one line too (its counts and last_sync describe one run) and is recomputed from entries on 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 resolve no longer keeps the local side of every conflict while reporting kept ours: neither side had translated_at. An entry holding a translations map 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 by hash as well as translated_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-string translated_at counts 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 resolve no longer warns about possible data loss on every ordinary lockfile merge. stats sat two context lines below generated_at and both change on every write, so git joined them into one region that opened inside stats and closed outside it — not a member list, so JSON.parse failed, 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. Writing stats on one line keeps the region a member list, and the same fixtures now report generated_at and stats.last_sync as 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 pull no longer writes source-language text into a target locale file. The merge's final ?? entry.value fallback 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 so reconstruct leaves 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 --verbose naming each key and file.

  • sync: deepl sync pull keeps 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 pull recognises a gettext plural entry that declares msgid_plural before it holds any msgstr[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 pull no longer discards existing translations for keys named after Object.prototype members. sanitizePullKeysResponse's accumulator now has a null prototype and keeps it through the merge, and mergePulledTranslations tests membership with Object.hasOwn, so a source key called toString, constructor, valueOf, hasOwnProperty or __proto__ no longer resolves to an inherited function that beat the real translation and then vanished from the file while the lockfile recorded it translated.

  • 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 the server: URL in the tms: block, rather than a bare Error: fetch failed at exit 1 for a refused connection or an unresolvable host while a hung server exited 5. The replay policy is deliberately unchanged, and a NetworkError, a 401, a 500 and a timeout each keep their own message.

  • sync: TMS server URL must use HTTPS now echoes the URL and points at http://localhost, which reaches a server bound to ::1, 0.0.0.0 or 127.0.0.1. The rule is unchanged: plain http:// is waived for localhost and 127.0.0.1 only, 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, and deepl sync pull enforces its 32 MiB response cap while reading the body rather than after parsing it.

  • sync: deepl sync audit no longer reads files outside the project root. Audit is driven by .deepl-sync.lock keys rather than globbed paths, and it joined them to the project root and read them with a bare fs.readFile, so a lockfile key of ../secretplace/en.json printed that file's string values in inconsistencies[].translations at exit 0. assertPathWithinRoot now 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 audit no 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 additive missingTargets field in --format json.

  • sync: sync.limits.max_file_bytes applies 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 reported unusable, 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/exclude pattern 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*)b took 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.bak was 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 --concurrency no longer makes a sync silently do nothing while reporting success — --concurrency abc produced NaN, which survived defaulting and started zero workers. --concurrency and --debounce now reject non-positive and non-numeric values at the boundary, sync.concurrency is validated in config, and mapWithConcurrency clamps 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.yaml instead). A --locale value that is not in target_locales now exits 7 with a ConfigError naming 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.yaml is written atomically.

  • sync: --auto-commit recognises 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 --watch mode 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's target_path_pattern cannot claim another's output.

  • sync: deepl sync --frozen reports an accurate key count when drift is caused by a newly added target locale, where it read Sync drift detected: 0 new, 0 stale keys.; the message also surfaces deletedKeys and mentions only nonzero categories. The drift exit code (10) is unchanged.

  • sync: Every ICU block in a message is protected, not just the first. parseIcu found 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 translating other yields a message with no other branch, 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 validate placeholder checking is ICU-aware, so a translated branch body no longer raises a spurious Extra 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_error never 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, and reassemble throws 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 reassembleIcu overwrites 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} and Hello {user} both become Hello __VAR_0__, which translateBatch deduplicates by design — but the same result object was assigned to every index and the restore loop edited result.text in place, so the second key was written with the first key's variable (Deleted %s files / Deleted %d files produced 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 translate was never affected.

  • sync: Template literals containing regex metacharacters in scanned source code (t(`item(${i}`)) no longer abort context resolution with a raw SyntaxError or 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: extract reported a single key, reconstruct wrote that one translation into every msgstr it walked past including the header's, an entry carrying #: comments was lost outright, an obsolete #~ run deleted the header, a plural entry lost its Plural-Forms rule, and extractTranslations reported 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 string Hola mundo, where the inner quotes used to be read as content and then escaped into the translation permanently. Applies to msgid and msgctxt as 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 — quote escaped CR to \r but unquote had no r case, so a carried entry's CR became \\r, then \\\\r, indefinitely. unquote now decodes \r, making the round trip idempotent.

  • po: A msgstr containing 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_plural and every msgstr[N]. The append path wrote the singular entry shape, so a plural key added to the source after the target file existed had ngettext returning the English source for every count while the lockfile recorded it translated. The path now emits msgid_plural and one msgstr[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. escapeValue writes 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 — which sync push then 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 as path=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 as greeting\: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 \X as 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 → value x=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. escapeValue also 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, so reconstruct passed the line through untranslated and appended a new [greeting] table — the source kept its English while the lockfile recorded the key translated and sync status read 100%. Quoted key segments are now matched with the logical dot-path derived through TOML.parse itself, 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 uses assertDistinctKeys, 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} = value and [${section}] were emitted unquoted, so a source key such as with space produced 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 like key = "…" 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-line k = """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, so messages.newkey parsed back as messages.messages.newkey and 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 name or quantity attribute 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 while sync status read 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 — reconstruct decided 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. extract never decoded entities and escapeAndroid replaced & last, so Terms &amp; Conditions became &amp;amp; after one run and &amp;amp;amp; after three. Entities are now decoded on extract via a single-pass decoder (so a literal &amp;lt; decodes to &lt;, 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 in git diff, and a key containing &, < or " broke the attribute outright. The key is now checked for control bytes (as are Android plural quantity values) and escaped for an attribute context, and extract entity-decodes attributes so such a key round-trips.

  • xliff: A trans-unit or unit id 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, so id="label.don't" truncated to label.don and one fetched translation was written into both units, shipping one string's translation under another's id — unreported, since XLIFF is exempt from assertDistinctKeys. 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 made extractNeedsReview read 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 whole name="value" pairs, so an attribute merely ending in state (xstate=) is not mistaken for it either. Two sibling markers are handled at the same time: approved="yes" becomes approved="no", and state-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 state attribute are no longer mangled — the <segment> and <target> patterns required bare tags, so in 2.0 extract returned nothing and reconstruct then 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 .po extracted zero entries and sync status reported 0% coverage at exit 0 while sync export emitted an empty XLIFF; TOML instead appended duplicates until the file no longer parsed. PO, TOML, iOS .strings and Java .properties now 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 with Expected 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 single stringUnit, discarding per-category translations the parser never surfaces as entries. Existing variations are preserved alongside the updated stringUnit, and the Localization type declares the field.

  • formats: ARB (Flutter) files with a UTF-8 BOM are readable, matching the JSON parser, and an .arb key whose name collides with an Object.prototype member (toString, valueOf, constructor, hasOwnProperty, isPrototypeOf, __proto__) is no longer dropped from the file it was billed for — key in data reported 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 tests Object.hasOwn and writes through the shared setOwnMember (moved to utils/own-members.ts).

  • formats: The JSON parser no longer pollutes Object.prototype via 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 through Object.defineProperty, so a key legitimately called toString translates like any other. The guard is now pinned by a test that fails if defineProperty is 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 with Error: 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 below sync.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 optional withMaxDepth on FormatParser, 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 a RangeError escaping any parser is caught at the same boundary. The direct deepl 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.md under the watched tree mapped to one <output>/doc.es.md and the last translation to finish silently replaced the others, at exit 0, with the count still reading Translations: 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-commit no longer loses most commits under an edit storm. WatchService.onTranslate was typed => void and called without await, 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 reaches onError, and the git work is queued one add/commit pair at a time (same harness after: 6 translations, 6 commits, 0 failures). Translations of different files still run in parallel.

  • watch: --auto-commit and --git-staged act 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 with is outside repository at …, with the watched repository nested git add silently did nothing, and with the working directory in no repository the session printed ⚠️ Not a git repository, skipping auto-commit untruthfully and exited 0. --git-staged failed silently inside a single repository with no unusual layout at all, since git diff --cached --name-only names 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-toplevel anchored 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 --output still commits the file it wrote. No case ever committed into the wrong repository's history.

  • watch: --git-staged recognises 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 (where realpathSync does not case-fold) or NFC versus NFD produced different keys for the very same inode and watch --git-staged translated 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, since os.tmpdir() is symlinked.

  • watch: --auto-commit no longer reports a session failure when there was nothing to commit — re-saving a file whose translated bytes are unchanged stages nothing, git commit --only then exits non-zero, and the undifferentiated catch counted that as a failure, exiting 12 although nothing went wrong. The staged state is now asked with git diff --cached --quiet rather than by matching git's "nothing to commit" wording, which a localized git translates, and a genuine failure is reported with git's own stderr instead 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.md under --to es was dropped with no request, no output and not even a 📝 Change detected line. 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. atomicWriteFile renames 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 watch debounces at the documented 500 ms and honours watch.debounceMs. The command forwarded a debounce only when --debounce was 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 by deepl config set but never read. Resolution is now flag, then watch.debounceMs, then a single exported default shared with the config schema. deepl sync --watch was already correct.

  • watch: --glossary without 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 honours defaults.sourceLang as every other command does, including on a direct WatchCommand.watch() call, which previously refused a command the CLI accepted. sync needs no equivalent, taking its source language from the required source_locale.

  • watch: filesWatched statistics 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. restorePlaceholders substituted 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: translate exits 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. sync is 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} and replaceAll brought 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. translateBatch skipped an empty text but left that slot null, the same value it uses for a failed request, so deepl translate printed a false 1 of 3 translations failed and then crashed at exit 1 with no output file, deepl sync deleted the key and recorded it failed so 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 with billedCharacters: 0, translateBatch is declared (TranslationResult | null)[] so the compiler forces the remaining failure case to be handled at each of the five call sites, BatchTranslationService records a missing per-index result as that file's failure, and the N of M translations failed warning counts only real failures.

  • translate: --no-cache is honoured for every structured i18n format — JSON, YAML, TOML, PO, XLIFF, Android XML, iOS .strings, .xcstrings, ARB, .properties and 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. translateBatch now takes the same serviceOptions shape as translate() and honours skipCache on both the read and the write, threaded through translateFile, translateFileToMultiple and translateStringsInBatches so single- and multi-target behave alike, and the bypass notice is emitted on the batch path. sync and batch expose no --no-cache and are unchanged.

  • translate: --output <dir> works for a single target language instead of failing with EISDIR, which the multi-target branch already honoured. The destination is now resolved once ahead of every branch, so deepl translate t.md --to ko --output dir/ writes dir/t.ko.md for text files, structured files and documents alike; dir and dir/ behave identically (the check is statSync().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 to MULTI_TARGET_CONCURRENCY (5) at once. The ceiling is 10 MiB, matching HARD_MAX_SYNC_LIMITS.max_file_bytes, and is checked with a fs.stat before 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 at deepl 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: N and the command still looked like success. A run stopped by one request-level rejection also reports that rejection's own code: 6 for a refused target_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_lang is 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 quoting target_lang cannot 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-run runs make the same language checks as text runs, through one entry point rather than eight call sites in five files — --to af --formality more failed locally for text and reached the network for a file, and --dry-run reported both as runnable along with --to 'not!!a!!lang'. --from is 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 --glossary list no longer trips the glossary rejection; and a dry run lowercases --to/--from as a real run does.

  • translate: --tag-handling-version is honoured for files and directories, not only for text — the shared option mapping carried --tag-handling but not the version, so with v2 now pinned whenever tag handling is on, deepl translate page.html --tag-handling html --tag-handling-version v1 silently sent v2.

  • translate: ℹ️ Cache is disabled and ℹ️ 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 json no longer inverts the --check verdict or corrupts the file --fix writes. checkText obtained the improved text by calling improve(), 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 json exited 8 claiming changes for any input, and write file.txt --fix --format json overwrote the file with the JSON document (recoverable only with --backup). --diff --format json likewise 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, --output and --in-place are byte-identical under both formats.

  • write: deepl write runs from a published install — diff is imported at the top of the write command but was declared only under devDependencies, so every command that loaded the module failed with Cannot find package 'diff'. --help did not surface it, because the module loads lazily.

  • write: --lang and --to accept 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 codes deepl languages prints and translate --to accepts.

  • write: The unsupported-style error links to the published docs URL instead of a docs/API.md path npm users do not have.

  • api: A /v2/translate or /v2/write response 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 text undefined into --output, {"text":12345} printed 12345, an object printed { a: 1, b: [ 2, 3 ] }, and null crashed 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 as unknown and the checks in a new src/api/response-shape.ts. write/correct had the identical hole and are fixed with it. Three distinctions: an absent or null translations/improvements field 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 as ETIMEDOUT.

  • 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's timeout 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 on error.code and 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, honouring Retry-After.

  • api: A blank or whitespace-only Retry-After header is treated as absent instead of collapsing 429 backoff into a tight retry loop — Number('') is 0, which passed the finite check and, being a real number, kept the jitter-backoff fallback from engaging. An explicit Retry-After: 0 is 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-After waits 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-detection and --preserve-formatting were absent from the key, so deepl translate "Hello" --to de and the same command with --translation-memory my-tm collided — the second returned the cached non-TM translation and reported cached: true — and two runs differing only in --ignore-tags returned each other's output. preserve_formatting does 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 when get() 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 than maxSize is 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. Repeated getInstance()/close() cycles no longer accumulate signal listeners until Node prints MaxListenersExceededWarning, 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 through PRAGMA 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>-shm so 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.db aside and recreated it empty, verified on a 2,646-entry cache that passed integrity_check. Load failures now leave the database and its -wal/-shm sidecars untouched: deepl translate and deepl write degrade to running without a cache (one warning per process, exit 0) while deepl cache … subcommands, which cannot run cacheless, fail with an actionable error.

  • cache: deepl cache enable / deepl cache disable persist cache.enabled to 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 stats likewise 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's SIGINT handler called process.exit(0), and because the cache singleton is constructed during service setup that handler ran before the sync engine's own — so deepl sync interrupted with Ctrl-C exited 0 and left .deepl-sync.lock.pidfile behind. 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 as sync --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 only Error: API key not set, and docs/API.md now describes the actual behaviour. The non-TTY --format table fallback notice carries the documented WARN prefix 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 trtranslate), and --version is no longer duplicated in the bash and zsh candidate lists.

  • cli: The global --timeout / --max-retries flags also apply to the API-key validation requests made by deepl init and deepl 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=k turned 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 and token=/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 (DE in config plus --from de). deepl config set defaults.sourceLang DE is 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 --version included.

  • config: config delete and the config read paths can no longer walk or mutate the prototype chain — __proto__, constructor and prototype segments are rejected, completing the config set hardening.

  • init: deepl init with stdin at end-of-file exits 6 with the documented non-interactive message instead of starting to prompt and then exiting 1 with a Node unsettled top-level await warning. Reached by docker run without -it, CI, and piped invocations: the command checked only --no-input, where the sibling guard in write --interactive also checks whether stdin is a terminal.

  • init/write/sync: @inquirer/prompts is declared as a dependency. It is imported at runtime by init, write --interactive and sync init while only the unused inquirer was declared, so it resolved through npm's hoisting: under a strict layout (pnpm, --install-strategy=nested) those commands failed with ERR_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 with ERR_UNSUPPORTED_DIR_IMPORT, making the whole programmatic surface unreachable and the published typings resolve to nothing for a nodenext consumer. deepl --help never exercised it, because the bin entry has its own module graph. Both directory specifiers now carry /index.js.

  • hooks: deepl hooks install resolves the hooks directory git actually reads (git rev-parse --git-path hooks), so it honours core.hooksPath (husky) and works inside linked worktrees and submodules where .git is a pointer file, instead of reporting success while writing a hook git never runs and crashing with a raw ENOTDIR. A repeat install no longer overwrites an existing hook backup (the next free .backup slot is used), the output prints the hook path and the backup path, and findGitRoot no longer loops forever when given a relative start path.

  • hooks: Generated git hooks no longer emit a broken install instruction — the pre-push template told users to globally install the unpublished deepl-cli name, which fails with ENOVERSIONS, 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 --fix on a 0600 secrets file no longer leaves it world-readable at 0644.

  • glossary: Deduplicating repeated --glossary flags no longer inverts the documented precedence — a repeat kept its first position, so --glossary base --glossary override --glossary base let override win terms both define although the user put base last. 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 from glossary_id, mints a third cache key for an identical request, or spends two of the five slots the API allows.

  • glossary: --glossary with a source language set only in config no longer fails. TranslationService merges defaults.sourceLang, so the request carries source_lang whether or not --from was 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 --glossary selection 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-br dictionary satisfied a request for pt-pt. A dictionary language now matches the requested one exactly or matches the base it reduces to, so de→en still covers --to en-us and a dictionary naming pt-br still matches --to pt-br.

  • glossary: Six smaller defects around the multi-glossary work. sync resolved 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-run and watch --dry-run reported 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 info printed raw dictionary languages beneath a normalized summary, so one glossary could show EN → DE under Source language: en; and a coverage error listed every dictionary of a multilingual glossary on a single line.

  • glossary: deepl glossary add-entry / update-entry reject 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 an Object.prototype member is no longer dropped or falsely flagged as a duplicate — tsvToEntries used a plain-object accumulator, so toString triggered 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>.glossary previously skipped the coverage preflight entirely. sync --dry-run resolves 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: --glossary is resolved after the command's local checks rather than before them, so voice a.ogg --to bogus --glossary my-terms no 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-hans is checked as en→zh-HANS; without --from there is no pair and the API still judges it.

  • voice: A session that ends with the audio transcribed but no translation for a requested --to language 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 include zh-HANS and en-GB and 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 voice reconnects after a transport failure. The socket error handler marked the stream ended before the close event that always follows, so the reconnect path (up to 3 attempts, --reconnect on 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: --quiet no 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 json is honoured on that path. The live display also keyed target rows by the requested spelling, so with --to zh-HANS a zh-Hans echo 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-gb and --to zh-hans exited 6 while --to en-GB worked, even though deepl languages prints 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) and fr-FR are returned by GET /v3/languages and accepted by the translate endpoint, but the bundled list did not contain them, so deepl translate --to de-CH failed locally with Invalid target language code — and the deepl languages the 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 --target marks Portuguese (pt) with [F]. Formality support is read from features.formality on GET /v3/languages rather than a static table: the v3 migration assumed v3 stopped reporting formality, but v2's supports_formality boolean had only become the presence of a formality key 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's category tiers are unaffected.

  • languages: --features no longer claims knowledge it does not have. Snapshot entries the API response omitted were every one rendered as the positive claim none — 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 a status no longer renders as the literal undefined, supportsFormality is no longer asserted false for 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 reads none, the Formality column is no longer disabled without being replaced, and the ? cell for an undescribed language has a legend. deepl languages also makes one GET /v3/languages request instead of two identical ones.

  • languages: A language GET /v3/languages describes 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 absent usable_as_source / usable_as_target flag 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 from deepl languages while the generator recorded it as core.

  • languages: deepl languages --format json with 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 languages printed names, feature keys and statuses verbatim and voice did 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 same sanitizeForTerminal the glossary and style-rule listings used, replacing control and zero-width characters with ?. It matters most for voice, whose live display clears a fixed number of lines. voice --format json keeps 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 apiKeyUnitCount for the account figure, because live responses omit unit_count for them, so both columns showed the same number; the account-wide account_unit_count the 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 json was already correct). A character count is read only for milliseconds billing, 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:languages refuses to write a snapshot that would break the CLI: an empty write list would collapse the WriteLanguage union to never, rejecting every --lang while naming no valid option, and a features matrix that stopped reporting glossary would retier all 125 languages as extended and make --formality and --glossary unusable everywhere. --check compares 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 .prettierignore so npm run format cannot make that check fail permanently. Both resources are fetched together and their failures reported together, so a key that cannot read resource=write no longer blocks regenerating the translation list. A thrown fetch or unparseable error body is reported as an error: 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. lang was interpolated into a single-quoted literal with no escaping and name escaped only quotes, so a response field containing ' }] as const; — or merely ending in a backslash — could append arbitrary code to src/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 resolves argv[1] through realpathSync, 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 audit registration 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.md and README.md said the last --glossary wins 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 omitted correct while claiming to match deepl --help, its environment-variable reference omitted NO_PROXY, and the README's table of contents omitted its Spelling and Grammar Correction section. Three further passages described behaviour that no longer exists: the voice --glossary row had picked up translate's repeatable/--from semantics (voice takes one glossary and requires neither), the write reference still said an unknown code is rejected locally, and the usage reference 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 usable node:sqlite) and the version error is documented under exit code 6, where it previously appeared nowhere. The NODE_MODULE_VERSION / npm rebuild better-sqlite3 entry 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), --config precedence (replaces the config file only; the cache path is unaffected), unknown-command and deepl detect sample output, and the nonexistent 10 MB PDF cap (the document limit is 30 MB uniformly) are corrected; the README now covers deepl sync, deepl tm and all nine style-rules subcommands, with dead in-page anchors repaired and docs/SYNC.md listed under Documentation. TROUBLESHOOTING.md's exit-code table gains codes 10–12 (SyncDrift, SyncConflict, PartialFailure) and its environment-variable table gains TMS_API_KEY, TMS_TOKEN, FORCE_COLOR and TERM; sync JSON-contract stability promises are rescoped from "1.x" to "within a major version"; the GitHub Actions recipes in docs/SYNC.md pin Node 24; CONTRIBUTING.md no longer cites Zod (validation is commander Option.choices() plus hand-written validators); and examples/README.md no longer references a nonexistent sample-files/ directory.

  • examples: examples/03-batch-processing.sh no longer hides five --output <dir> failures behind 2>/dev/null, || true and a (cached or completed) message that reported a cache hit for a call that had errored — so npm run examples reported 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.sh and examples/23-sync-ci.sh target Node 24 and set up Node explicitly. They pinned Node 20, or omitted setup-node entirely, so a copied recipe exited 6 on the Node floor. examples/39-advanced-translate.sh also no longer calls --tag-handling-version v1 the default; v2 is.

Security

  • sync: deepl sync validate was 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: a msgstr dropping a placeholder its msgid carries exited 0 reporting 1 warning(s), and that warning was Translation 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 with ERROR 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: --force is refused (exit 6) when there is no terminal to confirm it on, instead of treating "nobody can answer" as yes. The guard threw for CI=true and prompted on a TTY with no third branch, so in a git hook, cron job, make target, container entrypoint, Jenkins agent or plain deepl sync --force < /dev/null the 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-input was ignored on the same path, though it documents itself as aborting instead of prompting. Both now exit 6 with a message naming --yes as the only way to run --force unattended; an interactive decline is unchanged at exit 0 and now prints Aborted.. The gate is a single canPrompt() in utils/confirm.ts that confirm() itself uses, so the two cannot drift apart.
  • sync: A tms.server value in the checkout can no longer redirect the operator's environment-held TMS_API_KEY / TMS_TOKEN to a host of its choosing — .deepl-sync.yaml picked the destination and the only guard was scheme, not identity, so a hostile checkout plus deepl sync push delivered the credential and every translated string to a listener of its choice at exit 0. A hybrid allowlist plus trust-on-first-use now gates createTmsClient, so both push and pull are covered by one choke point: the hostname must appear in a new user-level tms.allowedServers list, 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-input or on a non-TTY the run fails closed at exit 7, naming the host and the exact deepl 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 at deepl config set rather than stored as approval that could never match. Loopback is not exempt; a credential inlined as tms.api_key/tms.token is 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/pull no longer send the TMS credential and translated strings to a host the destination-trust gate never approved. A tms.server whose path begins with //https://approved.example.com//evil.example.com, or http://localhost//169.254.169.254 — parses with the approved hostname, which is what the gate and the HTTPS/localhost checks key off, but buildUrl's relative resolution then sent the request to the other origin. The resolved request origin is now pinned to the approved one, with a ConfigError naming the redirected origin otherwise; a legitimate base path (https://tms.example.com/tms) is unaffected. The threat is a maliciously contributed .deepl-sync.yaml in a repo whose maintainer runs deepl 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; a config.json holding api.baseUrl redirected every request with nothing on any channel to say so, and -v printed the method and path but never the host. When the resolved endpoint is neither api.deepl.com nor api-free.deepl.com, an unconditional warning names the origin the key is going to and where the redirect came from — set by --api-url, or set 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 --quiet suppresses 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 as api-jp.deepl.com. The verbose request line now names the resolved origin, and ConfigService gained a configFilePath accessor.
  • api: An endpoint that keeps sending is now bounded in both bytes and wall-clock time. The shared axios instance never set maxContentLength or maxBodyLength, which default to unbounded, so a response body was buffered until the process died — and --timeout did 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 an AbortController deadline 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 as NetworkError (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/translate correlates 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 — translate on all 11 structured formats, plain-text batches, and sync. 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 validate emitted an OSC 52 clipboard write and a CSI 2J screen erase verbatim, and hooks install puts 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 in sync validate, and the choice labels in write/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 111 Logger.output call 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 and deepl translate ... > out.txt must reproduce the API's bytes exactly — the same rule ls and git apply. 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 from sanitizeForTerminal, 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 where git diff, cat, less and CI log viewers all render them. No API call is needed to trigger it: a contributor commits a valid locales/app.de.toml holding greeting = "Hola\u001B[2J" — TOML basic strings legally carry \uXXXX and the parser decodes it to a raw ESC — the key is then current, 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 sync exits 0 and says nothing, because the target-file read treats a parse failure as "no existing translations", and deepl sync status reports 100% (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's Char production, so there is no escape and no numeric character reference for it and expat, aapt2 and every conforming CAT tool reject the written file. One shared rule now lives in src/formats/util/control-chars.ts and 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), .properties extends its existing \uXXXX rule below U+0020, iOS Strings emits \UXXXX, which its own reader already decodes, PO gains the missing \r escape and refuses the rest with a ValidationError naming the entry, and Android XML and XLIFF refuse with a ValidationError naming 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 msgctxt and msgid with 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 one msgid extracted the same key as a legitimate msgctxt/msgid pair, and reconstruct wrote the smuggled entry's translation into that pair's msgstr: 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 such msgid in a catalog with no msgctxt anywhere 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 nested a: { 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 / \u0000 rather 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. sync skips 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 pull reading a target file whose keys collide leaves it untouched and records a key_collision skip rather than falling through to the source template, which would have rebuilt the locale down to the single key the export carried. .properties and 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 reported 3/3 keys while the lockfile came back with two entries and total_keys: 2, with no run ever converging. The same shape applied to a source path named __proto__, which fell out of entries wholesale 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 named constructor, toString, valueOf or hasOwnProperty read as an existing lock entry missing every field it should have, and computeDiff classified 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 in sync-status.ts and sync-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 stats crashed 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 no translations container crashed, and took the read-only sync status down with it; a per-file map that is a string crashed; entries as 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": [] beside total_keys: 2 and every later run re-billed the project in silence, forever; and a null translation and a string stats each 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>; entries that is not a map at all has nothing to salvage and takes the existing full-sync path with its own -entries-not-a-map tag. stats is no longer trusted at all: it is derived from entries and recomputed on read and on every write, so its counts can no longer disagree with the entries they describe. The repaired entries is rebuilt with setOwnMember, so a source path or key named __proto__ is carried across as an own property.
  • sync: sync pull no longer records review_status: human_reviewed for content it never verified was reviewed. Every key a pull applied was stamped human_reviewed in .deepl-sync.lock unconditionally, 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 the tms.server in a checkout is not one the operator chose, the endpoint controls the strings and the review label attached to them. Pulled entries now record status: translated with no review_status, which is the type's way of saying "unknown"; human_reviewed is still honoured when a person or another tool writes it, and --flag-for-review still writes machine_translated.
  • translate: A document upload response can no longer choose where this client sends its own follow-up requests. The document_id from POST /v2/document was 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.baseUrl or a proxy: against a stub answering a traversal-shaped document_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_id is 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 no document_id at 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 Error was returned live — an Error whose config.headers is an AxiosHeaders instance printed Authorization: 'DeepL-Auth-Key SUPER-SECRET-KEY-FROM-CONFIG' verbatim through util.inspect, while the sibling plain-object field on the same error was redacted correctly, and Map/Set leaked worse still, since their contents are not own properties at all. Every object is now rebuilt property by property on its own prototype, so util.inspect still names the class while never receiving the original instance, and Map/Set are rebuilt with their keys and members mapped; Date, RegExp, ArrayBuffer and 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 are defineProperty'd, so an own key named __proto__ lands on the copy instead of reaching the prototype setter. Hole two: the literal-value backstop read only process.env, while a config-file key wins precedence over it — so with a key in config.json and a different one in DEEPL_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 new Logger.registerSecret() closes it, called from the HttpClient constructor — the one place that sees whichever key won precedence, because it is where the key is attached to every request — and from the TmsClient constructor, which covers a tms.api_key or tms.token inlined in .deepl-sync.yaml. Redaction also recurses through objects, arrays and Error values with cycle protection rather than applying only to strings.
  • hooks: deepl hooks list no 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; and verifyIntegrity() — 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-commit with a forged marker plus a payload, wired up with git config core.hooksPath .githooks (the husky pattern), was reported ✓ pre-commit installed, with --format json saying true. list now 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 hash and the quoted-marker file reads not 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 — so modified renders in yellow and gives both readings rather than accusing, since the README invites customizing an installed hook. Tightening isDeepLHook also makes uninstall refuse to delete a file whose marker is only quoted, and makes install back such a file up instead of overwriting it.
  • hooks: deepl hooks install no longer writes an executable outside the repository because the repository told it to. resolveHooksDir asks git rev-parse --git-path hooks, which faithfully honours core.hooksPath including an absolute path anywhere on the filesystem, and the install then did an unconditional write plus chmod 0755 with no containment check — and core.hooksPath is repository-local git config, so it travels with a checkout rather than coming from the person running the command: git config core.hooksPath /outside/dir followed by deepl hooks install pre-commit printed success, exited 0, and left an 0755 script 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, --yes accepts it and still prints the notice to stderr so a scripted install records where the executable went. The refusal lives in GitHooksService.install, which needs an explicit allowExternal to proceed, so a caller that forgets to prompt cannot skip the gate. Only the repository-local setting is consulted — a global core.hooksPath is the user's own machine-wide choice — which is also what keeps linked worktrees and submodules quiet; containment is checked through symlinks; and uninstall is left alone, since it already refuses to remove anything that is not a DeepL hook. The predicate is now isWithinDirectory in src/utils/paths.ts, shared with assertPathWithinRoot.
  • config: The 0600 mode on the file holding the plaintext API key is enforced on every load, not just asserted at creation. config.json is created 0600 and its directory 0700, and neither was ever checked again, so a file restored from a tar or dotfiles backup, copied by rsync, or written by hand kept whatever mode it arrived with, forever, while load() read it with no stat, no repair and no warning. A group- or world-reachable config file is now tightened back to 0600 on 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, and deepl auth set-key to rotate it — and the warning is self-extinguishing, since the next run finds 0600. The directory is deliberately reported rather than repaired: its dirname is not always a directory the CLI owns, and deepl -c ~/deepl.json would have chmod'd the user's home directory to 0700, so it names the mode and the chmod 700 that would close it and changes nothing. Only the write bits are reported, since a merely traversable 0755 directory does not let anyone replace the file inside it; sticky directories are exempt, because mode 1777 is exactly the arrangement that makes a shared temp directory safe; and the cache directory gets the same report, while cache.db needed nothing, being 0600 on every open. Both mkdir sites are now unconditional recursive calls, closing the window where a directory could appear between an existsSync and 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 fixed config.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 left config.json as that symlink so every later write followed it too. The mode is applied with chmod after creation, which the umask cannot widen.
  • config: deepl config set rejects __proto__, constructor and prototype path segments and resolves keys with Object.hasOwn, so crafted paths cannot pollute Object.prototype.
  • sync: A sync target path can no longer begin with -, closing the defense-in-depth half of the git add/git commit option-injection fix. The argv side was already fixed, but .deepl-sync.yaml could 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/.github segment) — 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 how FORBIDDEN_TARGET_SEGMENTS is enforced twice: a literal pattern beginning with - fails at config load with ConfigError (exit 7), and every path resolveTargetPath renders is checked again with ValidationError (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, so res/values-{locale}/strings.xml, locales/zh-Hans.json and locales/-legacy/{locale}.json all 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. execFile prevents shell injection but not git's own option parsing, so a target_path_pattern rendering a leading dash reached git add as a flag and staged files of the pattern author's choosing, defeating the auto-commit preflight that exists to bound the staged set; git commit then ran with no pathspec and committed the whole index, so a separately staged .env.local or unfinished work landed in a chore(i18n) commit whose message described only the translation. Both sync --auto-commit and watch --auto-commit were affected — sync was partly shielded by its unrelated-modifications preflight, watch was not.
  • cache: The resolved API base URL is now part of every translation, write and correct cache key. One cache.db is 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 single deepl translate "hello" --to DE --api-url http://127.0.0.1:18111 served that endpoint's answer back for api.deepl.com for 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_VERSION moves to 3, so opening an existing DB drops its translation:, write: and correct: 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.bak sibling, 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.bak alongside 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 .bak unlinked immediately after. Because every target write goes through atomicWriteFile, 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.bak files; run with --verbose to see when this is skipped.
  • sync: No sync path may resolve into .git/ or .github/. FORBIDDEN_TARGET_SEGMENTS was checked against a literal target_path_pattern, so a bucket that simply omitted the pattern reached the default locale-substitution path unguarded: buckets.yaml.include: ['.github/workflows/en.yml'] made deepl sync write .github/workflows/de.yml whose run: 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 inside assertPathWithinRoot, 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 calls resolveTargetPath at 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 .github directory is unaffected, and .gitlab/, .gitignore and paths merely containing github as a substring are untouched.
  • sync: Glob patterns from .deepl-sync.yaml are 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 through braces, which caps only its input length while the expansion it produces is a product with no bound at all: a 1007-byte include pattern of 200 {a,b} groups killed deepl sync and deepl sync validate with FATAL 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-level ignore and context.scan_paths — rejecting anything that expands past 1000 paths or exceeds 4096 characters with a ConfigError (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 include globs can no longer escape the project root, and --dry-run no longer modifies the working tree. include entries were validated only as non-empty strings while target_path_pattern a few lines later already rejected .., and the unvalidated glob's literal prefix was resolved and handed to the stale-.bak sweep, which recursed with no containment check — deleting every old *.bak it found and re-creating any file whose .bak existed while the live file was missing or empty. Verified: include: "../../../../../../**/*.json" produced a sweep root of /var, an out-of-root .bak was 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. include entries are now rejected at config load for traversal segments and absolute paths (the check the sync init wizard 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 .bak copy, so a committed symlink directory plus a crafted target_path_pattern could read an out-of-root file into memory and clobber an out-of-root .bak sibling 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_locale and target_locales are validated against a BCP-47 whitelist at config load (previously a three-substring denylist), and target_path_pattern may not contain a .git or .github path segment — closing a write primitive where a "locale" like config plus 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.yaml discovery 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. restorePlaceholders looped while (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 $&/$1 inside a preserved value stay literal.
  • formats: A translated Android string can no longer break out of its CDATA section. escapeForReconstruct wrapped 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 sync indefinitely. 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 deepl command 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. globalSetup now clears DEEPL_API_KEY, TMS_API_KEY and TMS_TOKEN and points DEEPL_CONFIG_DIR at a temporary directory before workers fork.
  • init: deepl init masks 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 --pattern values 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-expansion is pinned to >=5.0.9 through an overrides entry, resolving GHSA-rgw5-rvv9-x895 (unbounded intermediate arrays) in the copy reached via minimatch; it is an overrides entry rather than a dependency because the CLI does not import the package and declaring it would fail check-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 in form-data (CRLF injection), and GHSA-58qx-3vcg-4xpx / GHSA-96hv-2xvq-fx4p in ws (uninitialized-memory disclosure and memory-exhaustion DoS). Production npm audit is back to zero vulnerabilities. Dev-tree instances of the brace-expansion advisories are intentionally left in place: npm's proposed remediation downgrades jest 30 → 25 and ts-jest 29 → 27, devDependencies are not installed by consumers, and the CI audit gate is production-only.
  • ci: ci.yml and security.yml explicitly request contents: read instead of inheriting the repository default token permissions.

[1.2.0] - 2026-04-25

Added

  • write: Japanese (ja), Korean (ko), and Simplified Chinese (zh, zh-Hans) are now accepted target languages for deepl write.
  • write: --tone and --style now 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 --tone and --style are unchanged — the same 9 styles and 9 tones are accepted, and the mutual-exclusion rule between --style and --tone is unchanged. See docs/API.md for supported target-language / style / tone combinations.
  • write: 4xx responses from the Write API that arrive while --style or --tone is set now carry an explicit recovery hint pointing at docs/API.md for supported target-language / style / tone combinations.
  • style-rules: Full CRUD — deepl style-rules create|show|update|delete alongside the existing list. create requires --name and --language. update accepts --name for a rename and --rules for replacing configured rules (PUT /configured_rules); at least one is required. --rules takes a JSON object of category → settings, e.g. '{"punctuation":{"quotation_mark":"use_guillemets"}}' — matching the DeepL API's two-level rule shape. delete supports -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 detailed show response), plus add-instruction <style-id> <label> <prompt>, update-instruction <style-id> <label> <prompt>, and remove-instruction <style-id> <label> subcommands. remove-instruction ships -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 list and deepl style-rules instructions <style-rule-id> accept --format table for aligned column output via cli-table3, matching the existing translate, languages, usage, and cache commands. In non-TTY output (pipe, redirect, CI), table falls back to plain text with a WARN line on stderr — same pattern used by deepl 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.shdeepl write with Japanese, Korean, and Simplified Chinese targets and tone / style applied to Spanish, Italian, French, and Portuguese variants.

Fixed

  • languages / cache stats / usage: --format table now actually renders a cli-table3 table on these commands. Previously the flag was advertised in --help but the action handler only branched on 'json', so --format table silently produced text output. Same non-TTY fallback as the other table commands.
  • sync: deepl sync export --output <path> (and other sync surfaces that call assertPathWithinRoot) 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 through fs.realpath before 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.

Security

  • api: Server-returned error messages are now passed through sanitizeForTerminal before being interpolated into the user-facing API error: … and Server error (5xx): … strings emitted from src/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.

[1.1.0] - 2026-04-23

Added

  • 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 with glayzzle/php-parser — AST allowlist over string-literal return-array entries; double-quoted interpolation ("Hello $name"), heredoc, nowdoc, and string concatenation are rejected with a ValidationError. 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 in deepl sync status via a new skippedKeys count. php-parser is lazy-loaded only when a laravel_php bucket is configured.
  • sync: deepl sync init auto-detects Laravel projects — composer.json at the repo root plus .php files under lang/en/ (Laravel 9+) or resources/lang/en/ (Laravel ≤8 / Lumen) triggers a laravel_php bucket 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 requires field on each detection pattern. Markers are plain fs.existsSync checks — never parsed — matching the filesystem-only stance of the sibling detectors. Laravel's composer.json is the first required marker; the ARB (Flutter) detector was retroactively tightened with pubspec.yaml to eliminate false positives for the very rare non-Flutter ARB use.
  • sync: deepl sync init now 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 — a locales/en.yml match used to emit a locales/en.yaml bucket pattern that wouldn't match at sync time. .yml and .yaml are now handled as separate detection entries so the extension round-trips faithfully.
  • sync: deepl sync init auto-detects go-i18n's root-level active.en.toml layout as a dedicated detection entry, emitting the active.{locale}.toml filename template. Previously only locales/en.toml / i18n/en.toml directory layouts were covered; root-level users had to fall through to the four-flag non-interactive path.
  • sync: deepl sync init auto-detects Rails namespaced layouts under config/locales/**/en.yml (and .yaml) — engines, concerns, and per-namespace splits are now recognized alongside the canonical config/locales/en.yml. The namespace directory is preserved in the generated bucket include: pattern.
  • sync: deepl sync init auto-detects Symfony's translations/messages.en.xlf layout as a dedicated XLIFF detection entry — distinct from Angular's src/locale/messages.xlf convention. Target locales are emitted as translations/messages.{locale}.xlf.
  • sync: sync.limits config block — per-file parser caps max_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 with ConfigError (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.lock content 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 export command — 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_instructions for 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_templates config — user-customizable instruction templates per HTML element type, overriding built-in defaults
  • translation.length_limits config — 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_templates is set but context scanning is disabled or no element types are detected
  • --batch / --no-batch CLI flags — --batch forces plain batch (fastest, no context); --no-batch forces true per-key context (slowest, max quality); default uses section-batched context
  • PushResult / PullResult types for sync 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 ConfigError with a remediation hint that names TMS_API_KEY / TMS_TOKEN and the relevant .deepl-sync.yaml fields.
  • context_sent field in lockfile translation entries — records whether source code context was included in the API request
  • character_count field in lockfile translation entries — records characters billed per key per locale
  • Live progress output during deepl sync — per-key key-translated events during translation and per-locale locale-complete events when each locale finishes, in both text and JSON formats
  • context.overrides config — 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 --frozen mode and exit code 10 for translation drift detection
  • validation.fail_on_missing and validation.fail_on_stale config options for granular --frozen drift 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)
  • estimatedCharacters and targetLocaleCount fields in JSON output
  • Dollar cost estimates in sync output and JSON (at DeepL Pro rates, $25/1M chars)
  • sync.max_characters config option — cost cap that aborts sync before translation if estimated characters exceed limit (override with --force)
  • sync.backup config option — pre-overwrite backup of target files (default true); .bak files cleaned up after successful sync
  • --watch mode — monitors source i18n files for changes and auto-syncs with debouncing (configurable via --debounce)
  • --flag-for-review marks MT translations with review_status: machine_translated in the lock file for human review workflows
  • Free API key (:fx suffix) support with automatic endpoint resolution to api-free.deepl.com
  • Custom/regional endpoint support (e.g. api-jp.deepl.com) that takes priority over auto-detection
  • sync export --overwrite flag — required to overwrite an existing --output file; protects against accidental clobbering
  • deepl sync status --format json error-mode output: failures now emit {error, code} JSON to stderr with the error class name (ConfigError, ValidationError, etc.) as the code
  • Translation memory support in deepl translate via --translation-memory <name-or-uuid> and --tm-threshold <n> — forces quality_optimized model, requires --from (pair-pinned), threshold is an integer 0–100 (default 75)
  • Translation memory support in deepl sync via translation.translation_memory and translation.translation_memory_threshold config keys, with per-locale overrides under translation.locale_overrides
  • Translation memory name-to-ID resolution is cached per run to avoid redundant GET /v3/translation_memories calls; TM files are authored and uploaded via the DeepL web UI
  • Verbose-mode logs at the glossary and translation memory resolution boundary: --verbose now 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 list subcommand — lists all translation memories on the account, mirroring deepl 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 json emits the raw TranslationMemory[] as returned by GET /v3/translation_memories. Help text on deepl translate --translation-memory now cross-references the new command
  • src/utils/uuid.ts — shared strict UUID regex (UUID_RE) + validateUuid / validateTranslationMemoryId helpers. validateTranslationMemoryId is dormant today (TM IDs only appear in /v2/translate POST 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 resolve now prints a per-entry decision report (kept ours / kept theirs / length-heuristic / unresolved) plus a summary, and accepts --dry-run to preview decisions without writing the lockfile.
  • sync docs: docs/SYNC.md Exit Codes table and docs/API.md sync Behavior bullet now cross-link to the canonical Exit Codes appendix.
  • sync: New sync.max_scan_files config key (default 50,000).
  • errors: SyncConflictError class in src/utils/errors.ts mirroring SyncDriftErrorExitCode.SyncConflict (11) is now throwable as a typed error so library consumers can instanceof-match the conflict case.
  • SECURITY.md: 1.1.x row 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.md and feature_request.md templates 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 --to uniformly across deepl translate and deepl write — the single most common vocabulary split flagged in cross-command usage. --lang / -l remain fully supported; nothing deprecated. The short form -t is intentionally not bound on write (it would collide with deepl translate -t, --to). Passing both --to and --lang with different values exits with a ValidationError; passing the same value works fine.
  • docs: docs/API.md gained a one-paragraph callout distinguishing deepl sync --locale (filter over locales already configured in .deepl-sync.yaml#target_locales) from deepl 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_files config field. Caps how many source files a single bucket's include glob may match before the bucket is skipped with a warning. Default 10000, hard ceiling 1000000. Guards against a misconfigured **/*.json that accidentally picks up a vendored subtree. Sibling fields max_entries_per_file / max_file_bytes / max_depth gate 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.

Changed

  • deepl sync cost 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 json output carries rateAssumption: "pro". docs/SYNC.md now 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 json output contract stabilized: the success JSON payload is now a curated SyncJsonOutput shape (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 init no-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 json mode 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 status documentation: 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 a Translation Status: header that the code never emits. The per-locale outdated field is now documented in the JSON field legend.
  • sync: deepl sync init now 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's detected[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 in DETECTION_PATTERNS for enumeration; only the first-pick order changed.
  • translate: Centralize TranslateOptions construction for deepl translate, deepl translate file.txt, deepl translate <dir>, and the document path in a new src/cli/commands/translate/translation-options-factory.ts. All four handlers now call buildBaseTranslationOptions() + 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-target targetLang stripping) stays in the handler. deepl sync is intentionally untouched — its TranslationOptions are built from resolved config with per-locale overrides and context_sent wiring, a different construction domain that lives in src/sync/sync-locale-translator.ts.
  • sync: Format-name knowledge consolidated under src/formats/registry.ts; --file-format CLI choices now derive from the registry. Prevents silent divergence between parser, CLI help, and registration.
  • sync: Removed per-parser sort calls (consumers sort once); extracted detectIndent to a shared src/formats/util/detect-indent.ts used by JSON, ARB, and xcstrings. Pure refactor, no behavior change.
  • sync: scan_paths file walk is now bounded (default 50,000 files; configurable via sync.max_scan_files in .deepl-sync.yaml) — exceeding the cap throws ValidationError with a suggestion, preventing CI wedges on misconfigured patterns.
  • sync: deepl sync push --help and deepl sync pull --help now include a TMS onboarding hint — the required tms: YAML block, the TMS_API_KEY / TMS_TOKEN env vars, and a pointer to docs/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.md push/pull sections get the same hint and cross-link.
  • sync: deepl sync --force help text now warns that the flag bypasses the sync.max_characters cost-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.md and docs/SYNC.md updated to match.
  • sync: Extract CLI exit-code enum to src/utils/exit-codes.ts (next to the errors module); adds SyncConflict (11) for sync resolve unresolvable-conflict exits. No runtime behavior change from the extraction alone; enables the envelope contract wiring.
  • sync: deepl sync init flag vocabulary aligned with the rest of sync: --source-locale and --target-locales are now the primary names, matching --locale in sync push/pull/status/export. deepl translate --target-lang is unchanged (operates on strings, distinct from locale-file semantics).
  • sync: Rename deepl sync --context / --no-context boolean to --scan-context / --no-scan-context to disambiguate from deepl translate --context "<text>" (string-valued). Bare --context / --no-context on sync now errors with a did-you-mean pointing to the new flag. deepl sync had 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 single applyCliOverrides helper in sync-config.ts. The TM-requires-quality_optimized guard now also fires at the CLI-override boundary, so --model-type latency_optimized is rejected with an actionable ConfigError when the loaded YAML has translation_memory set (previously the override silently bypassed the check).
  • sync: deepl sync glossary-report is renamed to deepl 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 a ValidationError (exit 6) and a did-you-mean hint pointing to audit. No deprecation alias — this is a pre-release rename; glossary-report never shipped in a tagged release. audit here means translation-consistency audit (term divergence across locales), not security audit in the npm audit sense.
  • 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 init interactive 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-batch to restore per-key behavior.
  • sync: deepl sync status --format json output shape declared stable across 1.x — {sourceLocale, totalKeys, locales[]} with coverage as 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.yaml IS 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 to VERSION/package.json and npm test output.
  • 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 init section cross-links to deepl sync init for continuous-localization setup.
  • sync exit codes: deepl sync partial-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 aliased ExitCode.PartialFailure to GeneralError (both 1), which prevented CI scripts from telling a partial sync outcome from a CLI crash. With this change, CI can safely branch on $? -eq 12 and retry only the failed locales via deepl sync --locale <failed,comma,separated>. The paired typed error class SyncPartialFailureError (exit 12, envelope code: "SyncPartialFailure") is added to src/utils/errors.ts, mirroring SyncDriftError (10) and SyncConflictError (11). Migration: any CI script that branched on $? -eq 1 to detect partial sync failure should switch to $? -eq 12; a generic $? -ne 0 check continues to work unchanged.
  • sync drift exit: deepl sync --frozen now exits soft (sets process.exitCode = 10 and returns from the action handler) instead of calling process.exit(10) directly. Observable exit code is unchanged at 10; the internal change lets in-flight writes, auto-commit steps, and any --watch event loop drain cleanly before the process exits. docs/API.md has promised this shape since 1.1.0 but the implementation drifted to a hard exit — now aligned.
  • tests: The shared tests/setup.ts afterEach hook now asserts that every nock interceptor 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 calling nock.cleanAll() from their own afterEach before 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 a ConfigError rather than risking data loss.
  • cache: Corrupted cache databases are now backed up aside as cache.db.corrupt-<timestamp> (plus any -wal / -shm sidecars) 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.warn names the backup path.
  • http: Retry backoff now uses full jitter (AWS-recommended variant): the delay for attempt n is a uniform random value in [0, min(INIT * 2^n, MAX)] rather than the fixed min(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. The Retry-After header path is unchanged — server-specified delays are honored verbatim.
  • http: Retries now emit a Logger.verbose line 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.

Deprecated

  • sync: deepl sync init --source-lang and --target-langs are deprecated in favor of --source-locale and --target-locales. The old flags continue to work but emit a stderr deprecation warning; they will be removed in the next major release.

Removed

  • sync: Dead onProgress callback and SyncProgressEvent interface from SyncOptions (never wired up).
  • sync: Remove silently-ignored --batch-size flag
  • sync: Remove 5 unimplemented config fields from types and docs
  • package.json: Drop exports["./cli"] subpath. It pointed at dist/cli/index.js, which runs program.parseAsync + process.exit at module load — any consumer who imported deepl-cli/cli would have had their own process terminated mid-import. The CLI remains available as a binary via the bin field.

Fixed

  • sync cost cap: When a brand-new target locale is added to an existing project, sync.max_characters now correctly includes the character cost of translating all current keys into the new locale in its preflight estimate. Previously, toTranslate was 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-run and the live run always report the same estimated character count for the same workload.
  • sync perf: Stale-lock entry cleanup now issues a single fg call 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 .bak sweep (sweepStaleBackups) is now scoped to the directories implied by each bucket's include globs 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, and deepl sync resolve --format json now 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) resolveTemplatePatterns loop over duplicate template-pattern entries. The accumulator in extractAllKeyContexts pushed one TemplatePatternMatch per 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). A Set-based dedup before the resolve loop collapses all per-file duplicates to at most one entry per distinct pattern string; MAX_LOCATIONS=3 downstream is unaffected since the first-seen filePath/line is sufficient context.
  • sync: Eliminated O(N²) Array.includes scan in the per-locale plural-slot hot path (sync-locale-translator.ts). Three call sites that tested batchIndices.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 a Set before the pluralSlots loop and use Set.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 push and deepl sync pull CLI 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. After pipe_pluralization was added as a third SkipReason, 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 shared formatSkippedSummary(skipped) helper in sync-tms.ts; the programmatic PushResult.skipped / PullResult.skipped shape is unchanged.
  • sync: deepl sync push and deepl sync pull now enforce the walker's skip-metadata partition at every inline parser.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 to TmsClient.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 exported partitionEntries helper in sync-bucket-walker.ts is 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, and PushResult/PullResult now surface a SkippedRecord with reason: 'pipe_pluralization' and key per leaked entry so silent-partition regressions are detectable.
  • sync: deepl sync init JSON detector now emits a glob bucket pattern for the directory-per-locale i18next layout (locales/en/*.json) instead of fabricating a nonexistent locales/en/en.json single-file path. Flat (locales/en.json) and dir-per-locale layouts are now separate detection entries.
  • sync: deepl sync init iOS detector no longer claims bare-root *.strings files — Apple's bundle model mandates .lproj, and the root-level glob was a relocation magnet that emitted {locale}.lproj/Localizable.strings target 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 init XLIFF detector no longer claims bare-root *.xlf / *.xliff files — CAT-tool dumps (Trados/memoQ/Xcode .xcloc extracts) are a false-positive magnet and the detector used to relocate them under src/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. Previously reconstruct() ran smol-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-toml is retained for extract().
  • sync: .deepl-sync.yaml now 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 in src/ cannot leave orphaned .js/.d.ts files that would ship via npm publish.
  • voice: Voice API no longer hardcodes the Pro endpoint; it follows the same endpoint resolution as all other commands.
  • auth: auth set-key and init now 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> and deepl sync pull --locale <x> now narrow the fan-out to the named locale instead of silently over-fetching every configured target. Commander was routing --locale to whichever scope declared it first, so the subcommand handlers received undefined and treated the filter as absent. The subcommands now resolve --locale via a shared resolveLocale(opts, command) helper that prefers the subcommand's value and falls back to the parent sync --locale, matching the existing resolveFormat pattern.
  • sync: Every sync subcommand now cleans up in-flight .tmp and .bak sibling files on SIGINT/SIGTERM (previously only sync --watch had this discipline), and sweeps stale .bak files older than sync.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 --watch now caches the validated sync config across debounced change events instead of reloading + revalidating it every tick. The cache invalidates on SIGHUP (explicit reload) or when .deepl-sync.yaml itself 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 a stderr warning at config-load time on every deepl sync … subcommand, including non-TTY contexts like CI. Previously the warning was only emitted on the sync push / sync pull code path, so a user running sync status or 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 init now 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 to 0 for 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 sync runs. Every completed (file, locale) pair was being printed twice — once live via the locale-complete progress event and again in a post-sync aggregated summary built from fileResults. 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.translateForLocale is 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: resolveTemplatePatterns now compiles each distinct pattern regex once per sync run instead of once per TemplatePatternMatch occurrence. Duplicate pattern strings (same template literal appearing in many source files) reuse the same RegExp.
  • sync: Template-pattern prep no longer reads every source file twice during deepl sync runs 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, and init now emit a machine-parseable JSON error envelope on stderr when --format json is 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 init also gains a --format json success envelope ({ok: true, created: {configPath, sourceLocale, targetLocales, keys}}) for project-bootstrap scripts. Envelope shape is guarded by an AJV schema and a shared assertErrorEnvelope test helper.
  • sync: deepl sync resolve now 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.lock manually and re-run deepl sync.
  • sync: deepl sync --watch --auto-commit now 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 --watch no longer leaks SIGINT/SIGTERM listeners across invocations and no longer serves a stale tmCache entry 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 push now issues push requests with bounded concurrency (default 10, configurable via tms.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 resolve now emits a loud warning when JSON.parse on 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 init non-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 next deepl sync run with a cryptic error.
  • sync: deepl sync --watch now 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 --watch now cleans up .bak files on SIGINT/SIGTERM even when a translation is in flight, and sweeps stale .bak siblings 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 --help now groups examples under First-time setup and Everyday use, showing the init--dry-runsyncstatus onboarding flow, and adds a pointer to deepl tm list for translation-memory discoverability.
  • sync: Acquires an exclusive advisory lock (.deepl-sync.lock.pidfile) at sync start to prevent two concurrent deepl sync invocations 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-commit now 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 from config.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 hung deepl sync push/pull indefinitely 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.yaml now exits 7 (ConfigError) instead of 6 (ValidationError), matching the documented exit-code contract in docs/SYNC.md and docs/TROUBLESHOOTING.md.
  • sync: deepl sync init now 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/prompts and either threw ExitPromptError or blocked indefinitely in CI.
  • sync: deepl sync --frozen --watch now exits with ValidationError (code 6). Previously the combination was documented as invalid but entered an infinite drift-check watch loop.
  • sync: Every ConfigError thrown from validateSyncConfig (.deepl-sync.yaml validation) now includes a remediation suggestion string pointing the user at the exact YAML field to fix. Previously ~15 of 18 throw sites provided only a title, defeating the advertised DeepLCLIError.suggestion consumer contract.
  • sync: deepl sync pull now 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: listTranslationMemories now paginates the GET /v3/translation_memories response using the documented page / page_size query parameters (max 25 per page, bounded at 20 pages). Accounts with more than 25 translation memories previously received a silently truncated list, which caused deepl tm list and 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's total_count indicates more pages are available.
  • sync: deepl sync --format json now emits {error, code} JSON to stderr on failure (matching the sync status --format json error 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 (and status, validate, audit) now emit the success JSON payload on stdout, not stderr. Previously deepl sync --format json > out.json produced 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: --from is required, TM rejects non-quality_optimized model types, glossary and TM are resolved once per invocation, and modelType defaults to quality_optimized when 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-level translation_memory but locale_overrides sharing a TM name across locales with mismatched pair support could silently reuse an incompatible TM UUID on the second locale.
  • translate: warnIgnoredOptions now actually fires for --translation-memory and --tm-threshold in modes that do not support them (e.g. directory, document). The keys were present in the handler supported-sets but missing from optionLabels, so the warning was inert.
  • translate: Harden TM name resolution against API-returned name pollution. resolveTranslationMemoryId now filters entries whose names contain ASCII control chars or zero-width codepoints before matching, and throws ConfigError when 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 — resolveGlossaryId now filters API-returned glossary entries whose names contain ASCII control chars or zero-width codepoints before name matching, and throws ConfigError with a UUID-disambiguation hint when two surviving entries share the same name. Mirrors the TM resolver defenses.
  • examples: examples/31-sync-ci.sh passes --file-format json to deepl sync init (was --format json, which is not a registered flag on init and would fall through to the interactive-prompt branch in non-TTY environments).
  • api: listGlossaries and listTranslationMemories errors now carry their method name as a [listGlossaries] / [listTranslationMemories] suffix on error.message. Suffix (not prefix) preserves deepl sync --format json stderr-shim consumer greps on canonical phrases like Authentication failed: Invalid API key.
  • sync: Reject translation_memory paired with a non-quality_optimized model_type at 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 keys lines for up-to-date locales.
  • sync: New-locale translations now correctly count in progress output.
  • sync: sync resolve conflict marker detection now works mid-file (added multiline flag to regex)
  • sync: sync validate, sync status, sync export, sync push, and sync pull now handle multi-locale formats (.xcstrings) correctly
  • sync: sync init auto-detection now generates valid glob patterns instead of {locale} placeholders that fast-glob cannot match
  • sync: resolveTargetPath supports target_path_pattern for 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, and ignore YAML blocks
  • sync: CLI overrides (--formality, --glossary, --model-type, --context) now merge into config
  • sync: --force mode 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: --frozen mode 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 --output now rejects paths that escape the project root and creates missing intermediate directories before writing
  • sync: deepl sync audit now 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 --locale and --format options on the bare deepl sync command (previously dropped during an earlier commander option-shadowing fix) and wire --sync-config end-to-end — commander camelCases the flag to syncConfig, but the handler was reading config, 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 assertPathWithinRoot guard in sync validate
  • sync: Fix resolveTargetPath $n locale injection via function callbacks
  • sync: Skip deleted diffs in sync-status coverage counts
  • sync: Validate HTTPS scheme in TmsClient
  • sync: Replace blocking readFileSync with 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-preservation String.replace calls
  • 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, and sync 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: --frozen now detects drift when a new target locale is added
  • sync: --dry-run reports pending new-locale translation in key counts
  • sync: Clean stale lock entries for files no longer matched by any bucket glob
  • sync: Merge config.ignore patterns into fast-glob for status, validate, push, pull
  • sync: Guard source_locale == target_locale in 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: --frozen guards stale lock entry cleanup and lock file write
  • sync: Preserve translateBatch index alignment by returning sparse array on partial failure
  • sync: restorePlaceholders replaces all occurrences (not just first)
  • sync: Fix context_lines default to 3 (matching documentation)
  • android-xml: Escape <, >, & in translations to prevent XML injection
  • json: Guard against 0-byte source files
  • translate: Invalid --to error is now concise — the 100+ language-code dump is removed; the message points at deepl languages for the full list.
  • examples: examples/30-sync-basic.sh and examples/31-sync-ci.sh now clean up /tmp/deepl-sync-demo/ and /tmp/deepl-sync-ci-demo/ on mid-script failure via trap cleanup EXIT (matching the pattern already in examples 32 and 34).
  • docs: docs/API.md and docs/SYNC.md now document the --format FORMAT option on deepl sync export (previously undocumented even though the flag was registered in src/cli/commands/sync/register-sync-export.ts). Clarified that on sync export the format choice affects only the error envelope on stderr; the success output is always XLIFF 1.2.
  • docs: docs/API.md corrected the note on the audit subcommand rename — the previous wording said "Prior to the 1.0.0 release, this subcommand was named glossary-report", which implied 1.0.0 users had access to it. The prototype name glossary-report never shipped in any tagged release; now worded consistently with the 1.1.0 CHANGELOG entry.
  • write: deepl write --interactive now fails fast with a ValidationError when stdin is not a TTY (e.g., a CI job that passes --interactive without --no-input). Previously the process would hang indefinitely on an @inquirer/prompts select call that a non-TTY stream can never answer.
  • translate: deepl translate --format table now falls back to plain [lang] text output with a WARN line on stderr when stdout is not a TTY. Screen readers and log scrapers no longer have to parse cli-table3's Unicode box-drawing characters; pipe --format table > out.txt produces parseable plain text instead of Unicode noise.
  • output: Spinners (ora) are now gated on process.stderr.isTTY at the Logger.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_COLOR is now explicitly honored in the CLI bootstrap by setting chalk.level = 0 when the env var is present. chalk already auto-detects NO_COLOR, but the explicit hook keeps the two color-detection paths in the codebase (chalk and isColorEnabled() in utils/formatters.ts) unambiguously in sync if chalk's auto-detection ever changes or is mocked in tests.
  • sync init: a bare process.exit(7) literal in register-sync-init.ts's JSON-output path now goes through ExitCode.ConfigError so 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. &amp;lt; (the literal 5-character string "&lt;") was silently collapsed to "<" because the decoder ran &amp;& on the first pass and then &lt;< 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 a ValidationError at 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.

Security

  • deepl sync --force billing defense: --watch --force is now rejected at startup with ValidationError (exit 6) — the combination would silently retranslate every key on every file change, creating an unbounded billing loop. Additionally, --force now 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), --force requires --yes explicitly — the process exits 6 with an actionable hint rather than proceeding silently.
  • Updated minimatch from ^9.0.5 to ^10.2.1 to fix ReDoS vulnerability (GHSA-3ppc-4f35-3m26)
  • sync: deepl sync pull now acquires the pidfile process lock (acquireSyncProcessLock) before writing any target files. Previously, a concurrent deepl sync (which holds the lock while writing target files) and deepl sync pull could race across multiple files — atomicWriteFile prevents torn individual writes but the multi-file read-modify-write cycle was unguarded. deepl sync push is read-only toward local files and does not need the guard.
  • sync: sanitizePullKeysResponse now enforces a hard cap of 50,000 keys (MAX_PULL_KEY_COUNT) on TMS pull responses. A response exceeding this limit is rejected with a ValidationError before 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 sanitizeForTerminal before appearing in thrown Error messages. Both the response body (capped at 1024 bytes) and response.statusText are 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 pull now 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_paths against project root with symlink protection
  • security: Use URL hostname check for tms.server (prevents localhost.evil.com bypass)
  • security: Encode tms.project_id in URL path
  • sync: sync push, sync pull, sync export, and sync validate now refuse to follow symbolic links when scanning source files, matching the policy already enforced by sync itself and sync-context. Previously a symlink inside a bucket's include pattern 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_TOKEN env values, and Authorization: ApiKey/Bearer <value> headers). Previously only DEEPL_API_KEY and DeepL-Auth-Key were covered, so TMS credentials could leak into logs via error messages, Headers dumps, or stringified fetch error bodies.
  • sync: Harden sync resolve conflict-fragment merge against prototype pollution. JSON-parsed fragments can carry __proto__/constructor/prototype as own properties; the merge now skips those keys and uses Object.create(null) accumulators so deepl sync resolve on a hostile .deepl-sync.lock cannot mutate Object.prototype.
  • sync: deepl sync export now rejects source-side paths that resolve outside the project root with a ValidationError, matching the --output destination guard. Previously a .deepl-sync.yaml with absolute source paths or symlinks pre-dating the fast-glob hardening could read files outside the configured scan root during export.
  • sync: scan_paths config 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 prior assertPathWithinRoot guard. No change to valid configurations; rejected configurations now produce a ConfigError with the offending pattern shown.
  • deps: npm audit fix — resolves axios GHSA-3p68-rc4w-qgx5 (SSRF via NO_PROXY normalization), axios GHSA-fvcv-3m26-pcqx (cloud-metadata exfil via header injection), and follow-redirects GHSA-r4q5-vmmm-2653 (auth-header leak on redirect). Not reachable from the CLI (baseUrl hardcoded to api.deepl.com; TMS uses native fetch); transitive advisories are now quiet.
  • sync: .deepl-sync.yaml and auto-detect-path reads (package.json, the first-match i18n file for key counting) now route through safeReadFileSync, which rejects symbolic links with a ValidationError. A hostile repo could previously ship a .deepl-sync.yaml symlinked 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 during deepl sync itself are unchanged — the bucket walker already refuses symlinks via fast-glob's followSymbolicLinks: false.
  • http: When HTTP_PROXY / HTTPS_PROXY is configured with an http:// proxy and the target endpoint is https://, 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 redacts X-Api-Key and X-Auth-Token headers, plus ?api_key= / ?apikey= query parameters. Previously only DeepL-Auth-Key, Authorization: ApiKey|Bearer, ?token= / &token=, and the DEEPL_API_KEY / TMS_API_KEY / TMS_TOKEN env-var exact values were covered. axios error dumps that include config.headers on TMS-style third-party backends (e.g., Phrase, Lokalise, custom REST endpoints) no longer leak these credentials via verbose logs.

[1.0.0] - 2026-02-17

Added

  • 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-After header support
  • Dry-run mode (--dry-run) for previewing destructive and batch operations
  • Cost transparency with --show-billed-characters flag
  • 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

Security

  • 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

Changed

  • Requires Node.js >= 20