diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 2e8b450a..b93748d8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -15,7 +15,7 @@ A clear, concise description of the bug. - **DeepL CLI version**: (run `deepl --version`) - **Node.js version**: (run `node --version`) - **Operating system**: (e.g., macOS 14.5, Ubuntu 22.04, Windows 11) -- **Install method**: (source, npm link, other) +- **Install method**: (npm global, npx, Homebrew, source checkout, npm link, other) ## Reproduction Steps diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29da1801..36e95864 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,10 @@ jobs: strategy: matrix: - node-version: [24] + # 24.15.0 is the `engines.node` floor — the release where node:sqlite + # stopped emitting an ExperimentalWarning. Bare `24` resolves to the + # latest 24.x, so without the pin the declared floor is never exercised. + node-version: ['24.15.0', '24'] steps: - uses: actions/checkout@v7 @@ -27,8 +30,61 @@ jobs: cache: npm - run: npm ci + # Ahead of lint: reformatting can move an eslint-disable directive off the + # line it suppresses, so drift should be reported as formatting rather + # than as the lint failures it goes on to cause. + - run: npm run format:check - run: npm run lint - run: npm run type-check - run: npm run check-deps - run: npm run build - - run: npm test + - run: npm run test:coverage + + # The suites run from the working tree, so nothing above proves the published + # artifact works. `bin` and the package entry have separate module graphs — a + # broken entry point ships while the CLI still passes every test. + package: + name: Packaged artifact + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: '24' + cache: npm + + - run: npm ci + - run: npm run build + + - name: Pack + id: pack + run: echo "tarball=$(npm pack --silent)" >> "$GITHUB_OUTPUT" + + - name: Install globally and run the CLI + env: + TARBALL: ${{ steps.pack.outputs.tarball }} + run: | + npm install -g "./$TARBALL" + deepl --version + + # From a throwaway consumer package, because Node does not resolve + # bare specifiers out of the global prefix. + - name: Import the package entry as a dependency + env: + TARBALL: ${{ steps.pack.outputs.tarball }} + run: | + tarball_path="$PWD/$TARBALL" + consumer="$(mktemp -d)" + cd "$consumer" + npm init -y >/dev/null + npm install "$tarball_path" + node --input-type=module -e " + const m = await import('@deepl/cli'); + const keys = Object.keys(m); + if (keys.length === 0) { + throw new Error('package entry resolved but exported nothing'); + } + console.log('package entry exports:', keys.join(', ')); + " diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 529896cf..392ab97a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,5 @@ name: Release -# This workflow only records the GitHub Release for a tag. Publishing to npm -# happens from GitLab, so no `npm publish` step or NPM_TOKEN secret belongs in -# this repository. on: push: tags: @@ -18,8 +15,6 @@ jobs: steps: - uses: actions/checkout@v7 - # A tag can be pushed at any commit, so a mislabelled tag would otherwise - # mint a Release whose title disagrees with the version it contains. - name: Verify tag matches package version run: | tag_version="${GITHUB_REF_NAME#v}" @@ -29,8 +24,6 @@ jobs: exit 1 fi - # Release notes come from the tag's CHANGELOG section; releases are - # immutable once published, but notes stay editable afterwards. - name: Extract changelog section id: notes run: | @@ -44,7 +37,6 @@ jobs: echo "has_notes=true" >> "$GITHUB_OUTPUT" fi - # Skipped when the release exists, so re-runs are idempotent. - name: Create GitHub Release env: GH_TOKEN: ${{ github.token }} diff --git a/.gitignore b/.gitignore index af0927de..f2f12138 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,12 @@ # Dependencies node_modules/ -.npm # Build outputs dist/ -build/ -lib/ *.tsbuildinfo # Testing coverage/ -.nyc_output/ # Environment variables .env @@ -19,11 +15,6 @@ coverage/ # Secrets and credentials *.pem *.key -*.p12 -*.pfx -*.cert -*.crt -credentials.json # IDEs and editors .vscode/ @@ -34,47 +25,20 @@ credentials.json # OS files .DS_Store -Thumbs.db -Desktop.ini - -# Debug logs -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* - -# Runtime data -*.pid -*.seed -*.pid.lock # Logs -logs/ *.log -# Cache and temporary files -.cache/ -.temp/ -.tmp/ -*.tmp +# Caches .eslintcache -# DeepL CLI specific -.deepl-cli/ +# SQLite databases; the cache lives outside the repo, but a stray +# DEEPL_CONFIG_DIR could land one here. Fixture databases are committed. *.db +!tests/fixtures/*.db *.db-journal *.db-wal *.db-shm # Claude Code local tooling (settings, hooks, agent worktrees, session state) .claude/ - -# Playwright MCP local state -.playwright-mcp/ - -# Beads issue tracking (local) -.beads/ -issues.jsonl - -# QA harness -qa/ diff --git a/.prettierignore b/.prettierignore index aa7de81f..03e611fe 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,15 @@ -node_modules +# The format scripts only cover src/ and tests/, but prettier reaches +# everything below when pointed at the repo root. + +# Build and coverage output dist coverage -*.log -.DS_Store + +# Generated from GET /v3/languages; check:languages compares the raw text, so +# reformatting it would report permanent drift. +src/data/language-entries.ts + +# Fixtures are byte-exact test inputs. Some encode the very thing under test: +# en-reserved-words.yaml quotes "yes"/"no" so YAML 1.1 cannot read them as +# booleans, and prettier rewrites those quotes. +tests/fixtures/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 94829214..6db605f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,154 +7,304 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added +## [2.0.0] - 2026-08-10 -- **cli**: `deepl correct` command (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 correct endpoint does not accept. Results are cached under a separate `correct:` namespace so corrections and rephrasings of the same text never collide. +**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](docs/MIGRATION.md)** — read it before upgrading, starting with [Exit codes that moved](docs/MIGRATION.md#exit-codes-that-moved). -- **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. A corporate `HTTPS_PROXY` was previously applied to every request, including one 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. -- **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. -- **cli**: `t` and `w` command aliases for `translate` and `write` ([#12](https://github.com/DeepL/deepl-cli/issues/12)). The aliases appear in `--help` output and in bash/zsh/fish shell completions. `w` is deliberately assigned to `write` rather than `watch` — write is a primary API feature, watch a workflow helper. +### Added -- **ci**: Pushing a `v*` tag now creates a GitHub Release, with notes extracted from that version's CHANGELOG section and generated notes as a fallback when the section is missing. This is why the repo has 17 tags and zero Releases. The workflow does **not** publish to npm: releases are published from GitLab, which is the source of truth and mirrors to GitHub, matching the other DeepL client libraries. The tag is checked against `package.json` first, so a mislabelled tag cannot mint a Release whose title disagrees with the version it contains. +- **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 ` and `--max-retries ` 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. -- **cli**: Running under Node.js < 24 now fails fast with a clear one-line error (exit 6) instead of surfacing a raw `node:sqlite` ExperimentalWarning or crashing later. +- **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](https://github.com/DeepL/deepl-cli/issues/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 -- **tests**: A fast-check property suite (`tests/property/`) now 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 by default (`FC_NUM_RUNS` overrides; `FC_SEED`/`FC_PATH` replay a recorded counterexample). The suite found the U+2028 TOML corruption and the `.properties` leading-space loss fixed in this release; example tests document decisions, properties enforce laws. -- **tests**: Tests that assert only inside a `catch` block now declare their assertion count, so they fail instead of passing with zero assertions when the command under test unexpectedly succeeds — the failure they exist to detect was the one they could not see. Sites that already assert on the success path are unchanged. `npm test` also refuses to run against a **stale** `dist/`, not just a missing one: the suites execute the built CLI, so a stale build reported results that did not describe the current source. A new suite checks every documented `deepl …` invocation in the README and docs against the CLI's real surface, so a command or flag that drifts out of existence fails CI rather than waiting for a reader to find it. -- **docs**: The README leads with the npm install path and marks Homebrew as pending until the tap exists. Install instructions target `@deepl/cli` (10 occurrences across `docs/SYNC.md`, four example scripts, `examples/README.md`, and the git-hook template in `src/services/git-hooks.ts`). The README installation section documents three install paths — npm (`npm install -g @deepl/cli`), from source, and Homebrew (`brew install deepl/tap/deepl`, marked pending until the tap ships) — with an explicit Node.js 24 prerequisite, replacing the `better-sqlite3` native-compilation caveat (Xcode CLT / python3-make-gcc), which no longer applies. The `TROUBLESHOOTING.md` `NODE_MODULE_VERSION` / `npm rebuild better-sqlite3` entry is replaced by accurate guidance for the one remaining cache-degradation cause (running on Node < 24). Six stale `DeepLcom` GitHub URLs now point at the `DeepL` org directly. `CONTRIBUTING.md` states the Node 24 development prerequisite, and `SECURITY.md`'s supported-versions table reflects that only 2.x is a published, supported line. -- **tests**: Removed the global manual mocks for `p-limit` and `fast-glob` (`tests/__mocks__/`). Because a manual mock for a node module is auto-applied to every suite, and `resetMocks: true` strips its implementation, `pLimit(n)` resolved to `undefined` and `fg(...)` resolved to `undefined` in all 236 suites — so no test exercised a real concurrency limit or a real glob walk, and two concurrency defects reached a release candidate undetected. The suites that need these mocked declare them explicitly with their own implementations, so nothing depended on the global versions. Added `tests/unit/concurrency-limiting.test.ts`, which asserts that `p-limit` rejects a non-positive concurrency, that peak overlap never exceeds the limit, and that `fast-glob` returns real paths — it fails if either global mock is reintroduced. -- **tests**: Suites that shell out to the bare `deepl` command now run this tree's built CLI via a PATH shim installed in jest `globalSetup`, instead of whatever `deepl` happens to be globally installed. 23 suites (392 tests) previously required a global install — absent one they all failed with `command not found`, and present one they silently tested the installed version rather than the working tree. The shim lives in the real environment before jest workers spawn (a `setupFilesAfterEnv` hook cannot do this: test code sees a copied `process.env` that child processes never inherit). CI's now-redundant `npm link` step is removed, and a new unit suite pins that `deepl` resolves to the shim and reports the tree's version. -- **tests**: `npm test` now fails fast with an actionable message when `dist/cli/index.js` is missing. `dist/` is gitignored and `npm test` does not build, yet all three test tiers execute the built CLI, so a fresh checkout previously failed hundreds of tests with errors that never mentioned the missing build. -- **tests**: Jest's haste map no longer indexes `.claude/` (via `modulePathIgnorePatterns`). A leftover agent worktree under `.claude/worktrees/` duplicated the manual mocks in `tests/__mocks__/` and made every jest run emit a `jest-haste-map: duplicate manual mock found` warning; test files in worktrees were already excluded, but module indexing was not. Verified warning-free with a worktree present. -- **BREAKING — package**: The package is now published as the scoped **`@deepl/cli`** (previously the unpublished working name `deepl-cli`). Scoped packages default to restricted visibility, so `publishConfig.access: "public"` is set explicitly — without it the publish fails. The `bin` name is unchanged: the command is still `deepl`, and 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 instead of relying on the redirect from the legacy `DeepLcom` org name. -- **BREAKING — cache/runtime**: The translation cache now uses Node's built-in `node:sqlite` module instead of the `better-sqlite3` native addon, and the CLI consequently requires **Node.js >= 24** (`engines.node` is now `>=24.0.0`). Node 24 is the first release where `node:sqlite` is non-experimental; Node 18/20 lack the module entirely and Node 22 would both warn on every invocation and downgrade the bundled SQLite (3.50.4 vs 3.53.1). Existing cache databases are read in place with no migration — the on-disk format is unchanged (SQLite 3.53.2-written files verified readable, WAL mode and `user_version` stamp included). **Migration**: upgrade to Node 24 (current LTS), e.g. `nvm install 24`; no other action is needed, and the cache keeps its contents. -- **ci**: `.nvmrc` now pins Node 24, matching the CI matrix. It still read `20`, so `nvm use` handed local developers a runtime that cannot load `node:sqlite` and does not satisfy `commander`'s `engines.node >=22.12.0`. -- **build**: `npm run build` now runs a `clean` step first, removing `dist/` and `tsconfig.tsbuildinfo` before compiling. Without it, `tsc` leaves output for sources that no longer exist, so a file rename could ship stale artifacts — verified reproducible: planting a stale module under `dist/sync/` and rebuilding left both files in `npm pack` output. Removing the build-info file is required too; deleting only `dist/` makes the incremental compiler report the output as up to date and emit nothing. -- **tests**: Raised the timeout for the `watchAndSync` test block to 30s and replaced its fixed-round setup flush with a wait on an observable readiness signal. These tests intermittently exceeded the 10s default on CI runners — always as timeouts, never assertions — failing unrelated dependency PRs. Suite duration was measured to be flat across 250/25/5 flush rounds, so the historical 20 → 50 → 250 escalation could not have addressed it; the cost is runner CPU starvation (1.8s locally vs 10.8s observed on CI). The flush now also fails with the pending state rather than a bare timeout if setup genuinely stalls. -- **deps**: `commander` 14.0.3 → 15.0.0. commander 15 is ESM-only (`"type": "module"`) and requires Node >=22.12.0, so it was unmergeable until the Node 24 baseline landed. Jest's `transformIgnorePatterns` allowlist gains `commander` so ts-jest transforms it under CommonJS test execution; without that, 28 suites fail to load with `SyntaxError: Cannot use import statement outside a module`. -- **ci**: Test matrix, release workflow, and security workflow now target Node 24 (previously Node 20 and 22). Node 20 reached end-of-life in April 2026, and Node 24 is the current LTS. This aligns CI with the runtime the project is moving to; the `engines.node` range is unchanged in this entry and is bumped separately. -- **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 via a commander `preSubcommand` hook. Measured on `--version`: ~144 ms → ~80 ms median, 358 → 137 modules loaded. Help output and invalid-value errors are unchanged. -- **perf**: YAML reconstruction now indexes every string slot in a single document walk instead of calling `setIn`/`deleteIn` per key, scaling roughly linearly with file size — measured ~3.6 s → ~170 ms for a 16,000-key file. Batched deletion also fixes a correctness bug where removing several items from one sequence shifted indices mid-iteration and deleted the wrong entries. -- **batch**: Plain-text batch translation now 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 rather than the whole tree. Batch grouping also measures the form-encoded body size (what the API's 128 KiB limit actually applies to) instead of raw UTF-8 bytes, so CJK-heavy batches whose percent-encoding inflates ~3x are split correctly instead of being rejected server-side. -- **sync**: Backups are written as `.deepl.bak` instead of `.bak`, and the stale-backup sweep considers only the `.deepl.bak` suffix — a user's own `*.bak` files are never touched again. The sweep also no longer re-creates a target file that was deleted; it restores a backup only over a sibling that exists but is empty. **Migration**: `.bak` files from earlier versions are no longer swept or restored — delete leftover `.bak` files manually if desired. -- **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 language codes uppercase and 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 — when a `.deepl-sync.yaml` exists and the CLI is 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. +- **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` / `` instead of comparing the source against itself. An untranslated entry (empty `msgstr`, no ``) 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 --output ` 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 ` 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 ` 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](docs/MIGRATION.md#typescript-consumers). +- **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 `.deepl.bak` instead of `.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 `.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 -- **repo**: The `VERSION` file and `.npmignore`. Nothing read `VERSION` — `deepl --version` reports `package.json`'s value — so it was a second, hand-edited source of truth that could only drift; use `npm version X.Y.Z --no-git-tag-version`, which updates the manifest and lockfile together. `.npmignore` 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 library consumers unresolvable stack frames and broken go-to-definition. -- **deps**: `inquirer`, which no source file imported (see the `@inquirer/prompts` entry under Fixed). -- **deps**: `better-sqlite3` and `@types/better-sqlite3`. The production dependency tree no longer contains any native addon, removing the entire class of ABI-mismatch failures (`ERR_DLOPEN_FAILED` / `NODE_MODULE_VERSION` errors after a Node major upgrade, e.g. via `brew upgrade node`), 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 missing (Node < 22.5.0) warns once and runs uncached rather than crashing, and never touches the cache database. -- **BREAKING — sync**: `deepl sync init --source-lang` and `--target-langs`, the deprecated aliases introduced in 1.x, are removed and now fail with `unknown option` (exit 1). Use `--source-locale` and `--target-locales`. This is the documented removal of 1.x aliases at the 2.0 cut. `deepl translate --target-lang` is unaffected — it is the API's wire name, not a deprecated alias. +- **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 -- **formats**: TOML reconstruction escapes U+2028/U+2029 (Unicode line/paragraph separators) in double-quoted values, and literal-string values gaining one fall back to double quotes. Written raw, these characters broke the entry-line scan on the *next* sync (JavaScript's `.` excludes line terminators), which re-appended the key as a duplicate and made the third sync fail to parse the file at all — first sync fine, second silently corrupting, third crashing. Found by the property-based round-trip suite. -- **formats**: `.properties` reconstruction escapes leading spaces in values (`\ `), which the value parser otherwise strips on the next read — a translation beginning with a space silently lost it on every subsequent sync. Leading tabs, trailing spaces, and newlines were already escaped correctly. Found by the property-based round-trip suite. -- **sync**: `--auto-commit` now recognises its own translation output rather than only the files a given run wrote, which fixes two related problems. A translation left on disk by an earlier refused run was classed as an unrelated modification, so auto-commit refused forever and the user had to commit it by hand; it is now committed once the genuinely unrelated changes are dealt with. And in `--watch` mode, where the same path runs once per trigger, a trigger that translated nothing skipped the checks entirely and reported success while a commit was still owed. Staging is also driven by what is actually dirty, so a rewrite that produced identical bytes no longer attempts an empty commit. Ownership is derived from the lockfile's tracked source files, with each file matched to its own bucket so one bucket's `target_path_pattern` cannot claim another's output. -- **sync**: Re-running `deepl sync --auto-commit` after a refusal no longer reports success without committing. A refused run still writes its translations to disk, so the retry found nothing to translate; the preflight was skipped in that case and the command exited 0 with no commit made, the translation still uncommitted and the tree still dirty. The repo-state checks now run whenever `--auto-commit` is requested, so the refusal is reported identically on every attempt. Staging and committing are still skipped when there is nothing to commit. Two tests covered this path but could not see the defect: their assertions ran only inside a `catch` the retry never entered, which also hid an assertion that could never have matched (it looked for "detached HEAD" against a message reading "HEAD is detached"). -- **write**: `deepl write` now runs from a published install. `diff` is imported at the top of the write command but was declared only under `devDependencies`, which consumers do not install, so every command that loaded the module failed with `Cannot find package 'diff'` before producing output. `--help` did not surface it because the module loads lazily. +- **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..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` / ``) — 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 `` and an empty `` 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 `` 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 ``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 `` 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 `` 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 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 ` 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 `unshift`ing 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. `` 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 (``, ``, ``, ``) 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 `` element whose name looks like an array index (`x.0`, beside a ``) 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 `` 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 & Conditions` became `&amp;` after one run and `&amp;amp;` after three. Entities are now decoded on extract via a single-pass decoder (so a literal `&lt;` decodes to `<`, not `<`) and `&` is escaped before `<`/`>`; a three-run identity sync is a fixed point. +- **android**: A self-closing element (``) is no longer deleted together with the element that follows it, a value whose CDATA body contains `` is no longer truncated on extract, and plural `` 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 `` / ``, 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 `` and `` patterns required bare tags, so in 2.0 `extract` returned nothing and `reconstruct` then deleted every ``, and in 1.2 an existing `` was treated as absent so a second `` 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 `` between `` and `` is also no longer rejected; only CDATA inside ``/`` 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 `` 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 ` 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 `/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 `.tmp..` 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 ` 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 ` 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 `-wal` / `-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 tr` → `translate`), 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. -- **init**: `deepl init` with stdin at end-of-file now 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; the existing test covered only the `--no-input` variant, which already worked. -- **write**: `--lang` and `--to` accept language codes in any casing and normalize them to the API's form. They previously compared against a mixed-case list with an exact match, rejecting the lowercase codes `deepl languages` prints and `translate --to` accepts — so the CLI's own discovery output was unusable with the command documented as consistent with `translate`. -- **translate**: The error raised for an unexpected API response pointed at a bug tracker under an unrelated third-party account rather than this repository's. -- **cli**: Shell completions and the did-you-mean suggester no longer offer hidden internal commands, and the suggester now knows about aliases and prefers a prefix match — `deepl tr` suggests `translate` rather than `tm`. `--version` is also no longer duplicated in the bash and zsh candidate lists. -- **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`; `docs/API.md` claimed quiet mode still showed such warnings and now describes the actual behaviour. -- **cache**: The size cap is now actually enforced. Size accounting previously lived in a process-local counter that drifted in both directions — downward when an overwrite's eviction deleted the very key being replaced (after which the cap stopped firing and the DB grew without bound), upward when `get()` deleted expired rows without decrementing (evicting entries that didn't need evicting), and negative when a concurrent process's rows were swept (disabling eviction for the process lifetime). The total is now always read from `SELECT SUM(size)`. Eviction now deletes oldest rows in batches until enough space is actually freed, instead of a one-shot estimate from the average row size that under-evicted whenever sizes were skewed. An entry larger than `maxSize` itself is skipped instead of stored — previously it wiped every other entry first (eviction deleted all rows and inserted the oversized one anyway), leaving a cache that re-wiped on every subsequent write. 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 — essentially every CLI invocation. Repeated `getInstance()`/`close()` cycles no longer accumulate process signal listeners until Node prints `MaxListenersExceededWarning` to stderr. Docs now describe eviction as oldest-first rather than LRU, which is what the code has always done (`timestamp` is only written on `set()`). -- **cache**: Only genuine corruption (`SQLITE_CORRUPT` / `SQLITE_NOTADB`) now triggers the rename-aside-and-recreate recovery. The constructor previously treated *every* unexpected error as corruption, so transient lock contention (`SQLITE_BUSY` — e.g. two concurrent invocations against a cache on a filesystem where WAL cannot be enabled) renamed a healthy cache aside, recreated it empty, and broke the other process's open transaction; and a DB written by a newer CLI version was destroyed instead of refused, defeating the schema check's stated purpose. Lock waits now go through `PRAGMA busy_timeout` (5s), and everything that isn't corruption propagates so the CLI degrades to an uncached run with a warning. When recovery does run, the backup's WAL/SHM sidecars are copied before the failed handle is closed (SQLite deletes them on close) and named `-wal` / `-shm` so SQLite can actually recover the preserved data — the old naming (`-wal`) orphaned the WAL, silently losing any rows not yet checkpointed. Rename-aside backups are pruned to the most recent three so repeated corruption cannot fill the disk. -- **sync**: `deepl sync pull` no longer discards existing translations for keys named after `Object.prototype` members. `sanitizePullKeysResponse` built its result on a plain object, and callers test membership with `pulledKeys[key] !== undefined` / `??` — so for a source key called `toString`, `constructor`, `valueOf`, `hasOwnProperty`, or `__proto__`, the lookup returned an inherited *function*, which is neither `undefined` nor nullish. The entry was therefore treated as a freshly approved TMS translation and won over the real one, then vanished from the file when `JSON.stringify` dropped the function value — while the lockfile recorded it as `translated` / `human_reviewed`, so no later sync repaired it. The accumulator now has a null prototype. -- **sync**: ICU plural/select messages with text around the block are now preserved. The detection pattern was anchored to the start of the string, so `You have {count, plural, one {# item} other {# items}} in your cart.` — arguably the most common real shape — was not recognised as ICU at all and the raw syntax went to the engine as prose. Confirmed against the live API: it returned `{count, Plural, ein {…} weiteres {…}}`, translating the format keyword *and* both selectors, leaving a message with no valid format type and no `other` fallback. Detection now matches an ICU block anywhere in the string, and the prose on either side becomes a translatable segment instead of being silently dropped from the reassembled output (trailing text was previously lost outright). Re-verified end to end after the fix: `plural`, `one`, `other`, and the `count` argument all survive while the surrounding prose is translated. Note this affects `deepl sync`, the only path with ICU preservation; `deepl translate` on a raw ICU string is unchanged. -- **sync**: ICU structural damage is now detected instead of reported as passing. `checkIcuBrackets` compared only brace counts and nesting depth, which 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, while tolerating reordered selectors (order is not meaningful in ICU). -- **sync**: A failed ICU segment no longer yields a part-English message reported as a successful translation — the message is marked failed and retried. `reassemble` also throws on a translation-count mismatch rather than filling the gaps with empty strings, which produced empty plural branches that render nothing for that category. -- **sync**: A locale that failed completely now fails the run. The check used `.every()`, so a run was only unsuccessful when *every* locale failed — with two locales where one succeeded and the other failed outright, the run reported success and exited 0, leaving the failed locale's file absent while CI went green. It now matches the documented contract for exit 12 (`docs/API.md`: "completed with at least one failed locale"): a locale that translated nothing and recorded failures fails the run, while a locale that partly succeeded still reports its per-key failures in the summary without failing the run, and a locale with nothing to do is simply up to date. `--auto-commit` additionally now refuses to commit an unsuccessful sync — it previously checked only drift and dry-run, so it would happily commit a state with a missing locale file. -- **sync**: Staleness is now judged per target locale, so `--frozen` can detect a locale that has fallen behind. `computeDiff` compared only the entry-level `source_hash`, so once one locale was re-synced after a source edit, the entry hash matched again and every *other* locale reported `current` **forever** — no API call was ever issued for them, `sync status` showed 100%, and the CI gate whose entire purpose is catching out-of-date translations could not see it. A key is now stale if any configured target locale's stored hash lags the source or its last attempt failed. Relatedly, the failure check was previously unscoped (`Object.values(translations).some(failed)`), so **one** locale's failure marked the key stale for **all** locales and the next run re-translated locales that were already correct — overwriting human-edited files. Locales absent from `target_locales` are ignored, so a leftover entry for a de-configured locale no longer flags a key indefinitely, and a locale with no entry at all is still treated as a new-locale backfill (counted as new keys) rather than as drift. `sync status` computes the same per-locale determination inline instead of inheriting the shared source-level status. -- **BREAKING — sync**: Untranslated source text is never written into a target locale file and recorded as a translation. When a key's source was unchanged, the lockfile claimed the locale already had a translation, and the target file supplied none — because the file had been deleted to force regeneration, or held an empty value — the source string was written through as the "translation" and the lockfile kept its `translated` status, so **no later run ever corrected it**: the locale file silently retained English and the run reported success. Such a key is now re-translated, and a verbose message explains why. A translation that is present but deliberately empty is preserved rather than treated as missing. **Behaviour change to be aware of**: deleting a target locale file now causes its keys to be translated again (billed) instead of being backfilled with English — which is the point, but it does mean a deleted file costs a real re-translation. Two existing tests asserted the old fallback; neither the CHANGELOG nor `docs/SYNC.md` documented it, and both arrived inside a bulk development commit rather than as a deliberate decision, so they have been updated to the corrected contract. -- **api**: A blank `Retry-After` header no longer collapses 429 backoff into a tight retry loop. `Number('')` is `0`, which passed the finite check and returned a 0 ms delay — and because `0` is a real number the jitter-backoff fallback never engaged, so all retries fired back-to-back at an endpoint that was already rate-limiting the client. A blank or whitespace-only header is now treated as absent; an explicit `Retry-After: 0` is still honoured. -- **watch**: A file changed during its own translation is no longer translated twice concurrently. The debounce entry was deleted in the translation's `finally` rather than when the timer fired, so it removed whatever entry was current by then — usually a *newer* pending timer for the same file, which then could not be cancelled. A subsequent change therefore started a second translation racing to write the same output path, doubling API spend with a nondeterministic winner. The entry is now cleared when the timer fires, and only if it is still the entry that timer registered. -- **sync**: An invalid `--concurrency` no longer makes a sync silently do nothing while reporting success. `--concurrency` was parsed with a bare `parseInt`, so `abc` produced `NaN`, which survives `??` defaulting; `Math.min(NaN, n)` is `NaN` and `Array.from({length: NaN})` is empty, so **zero** workers were started and `mapWithConcurrency` returned an empty result having translated nothing and thrown nothing. Verified end-to-end: with a deliberately invalid API key, `deepl sync` correctly failed with exit 2 while `--concurrency 0`, `--concurrency abc`, and `--concurrency -3` each printed `Sync complete` with exit 0 and no output file. The flag (and `--debounce`) now reject non-positive and non-numeric values at the boundary, `sync.concurrency` is validated in the config like `tms.push_concurrency` already was, and `mapWithConcurrency` clamps to at least one worker as defence in depth. -- **cache/cli**: 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** (so `deepl sync && git commit` would commit a half-finished sync) 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 end-to-end as exit 130 with the lock released. Commands that own their shutdown — `sync --watch`, for which SIGTERM is the normal stop signal — opt out and still exit 0. -- **formats**: TOML multi-line (`"""` / `'''`) values survive a sync. Only the opening line was emitted verbatim, so body lines that happened to look like `key = "…"` were parsed as entries and **deleted from the value**, and the multi-line key — never marked as used — was re-appended at end of file, leaving a document that no longer parses (`trying to redefine an already defined table or value`). The whole block is now emitted verbatim and skipped, including the single-line `k = """text"""` form. Multi-line values remain untranslated, which is the existing documented behaviour. -- **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 — so they could not be recovered. Existing `variations` are now preserved alongside the updated `stringUnit`, and the `Localization` type declares the field so this cannot silently recur. -- **formats**: ARB (Flutter) files with a UTF-8 BOM are readable. `JSON.parse` rejects a leading BOM, so a BOM-prefixed `.arb` file failed outright; the BOM is now stripped on extract and reconstruct, matching the JSON parser's existing behaviour. -- **formats**: A translated Android string can no longer break out of its CDATA section. `escapeForReconstruct` wrapped the translation in `` with no escaping, so a value containing `]]>` closed the section early and the remainder was parsed as XML — allowing extra `` elements into a generated resource file. This was reachable without a malicious API response, because on translation failure the source string is written through verbatim and the source file is used as 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**: New TOML 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 because the intended key was therefore still missing, it 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. -- **formats**: Android XML entities no longer compound on every sync run. `extract` never decoded XML entities, and `escapeAndroid` replaced `&` *last* — so it re-escaped its own output. `Terms & Conditions` became `&amp;` after one run and `&amp;amp;` after three, verified. Entities are now decoded on extract via a single-pass decoder (so a literal `&lt;` decodes to `<`, not to `<`), and `&` is escaped before `<`/`>`; a three-run identity sync is now a fixed point. -- **formats**: Emoji and other astral characters survive `.properties` files. `escapeValue` iterated by code point but escaped with `charCodeAt(0)`, emitting only the high surrogate — `Hello 😀 world` was written as `Hello \ud83d world`, which cannot be decoded back. All UTF-16 code units of a character are now emitted, so an emoji round-trips as a complete surrogate pair. -- **formats**: XLIFF files carrying a `state` attribute are no longer mangled. `state` is a standard attribute that every CAT tool writes, but the `` and `` patterns required bare tags. For XLIFF 2.0 this meant `extract` returned nothing and `reconstruct` then **deleted every ``**; for XLIFF 1.2 an existing `` was treated as absent, so a second `` was injected, yielding schema-invalid output that retained the stale translation. Both elements now accept attributes and preserve them through a round-trip. -- **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: a Windows-authored `.po` file extracted **zero** entries, so `deepl sync status` reported `totalKeys: 0` and 0% coverage with exit 0, and `sync export` emitted an empty XLIFF. TOML was affected differently — reconstruct failed to match existing keys and appended duplicates, producing a file that no longer parses (`trying to redefine an already defined table or value`). All line-based parsers (PO, TOML, iOS `.strings`, Java `.properties`) now split on `/\r?\n/`; the latter two already tolerated CRLF, and the change makes that explicit rather than incidental. -- **glossary**: A glossary term named after an `Object.prototype` member is no longer silently dropped or misreported. `tsvToEntries` accumulated into a plain object and tested for duplicates with `entries[source] !== undefined`, so `toString` (and friends) triggered a spurious "Duplicate source" warning, and a `__proto__` term was swallowed by the prototype setter instead of being stored. Genuine duplicate detection is unchanged. -- **sync**: A lockfile entry whose i18n key is named `__proto__` no longer vanishes on every write — the key-sorting JSON replacer accumulated into a plain object, where that assignment invokes the prototype setter. Auto-glossary term extraction, which is keyed by untrusted source strings, was fixed the same way. -- **formats**: The JSON parser no longer pollutes `Object.prototype` via a `__proto__` key in a resource file, and now round-trips prototype-named keys as ordinary data. `hasKey` used `part in record`, which reports inherited members, and `setKeyWithParts` assigned with `current[part] = …`, where `current['__proto__'] = {}` invokes the prototype setter instead of creating a property. Membership is now an own-property check, and assignment goes through `Object.defineProperty`, so a key legitimately called `toString` translates like any other. -- **hooks**: Generated git hooks no longer emit a broken install instruction. The `pre-push` hook template told users to globally install the unpublished `deepl-cli` name — which fails with `ENOVERSIONS` — and now points at `@deepl/cli`. Reported by **@maa-xx** in #70, who correctly diagnosed that the documentation instructed readers to install a package that does not resolve; their docs fix was superseded by actually publishing the package, but this source-level occurrence was the one their PR missed and is now guarded by a regression test asserting generated hook output never references an unpublished package name. -- **cache**: A cache backend that fails to load (e.g. `better-sqlite3` ABI mismatch after a Node major upgrade, `ERR_DLOPEN_FAILED`) is no longer misclassified as database corruption. Previously the constructor's catch-all renamed the user's healthy `cache.db` to `cache.db.corrupt-` and recreated an empty database — verified to quarantine a 2,646-entry cache that passed `integrity_check`. Native-module load failures now leave the database and its `-wal`/`-shm` sidecars untouched; genuine corruption still triggers the rename-aside recovery. `deepl translate` and `deepl write` degrade to running without a cache (single warning per process, exit code 0) instead of crashing, since the cache backend is loaded lazily behind a warn-once latch; `deepl cache …` subcommands, which cannot run cacheless, fail with an actionable error suggesting a reinstall or matching Node version. -- **sync**: `deepl sync --frozen` now reports an accurate key count when drift is caused by a newly-added target locale. Previously the message read `Sync drift detected: 0 new, 0 stale keys.` because the frozen branch in `processBucket` short-circuited before promoting current-status keys missing a target-locale translation into `newKeys` — even though `--dry-run` against the same state correctly reported the backfill count. The drift exit code (10) is unchanged. The drift message now also surfaces `deletedKeys` and only mentions nonzero categories, mirroring the success-path summary format. -- **cache**: `deepl cache enable` / `deepl cache disable` now 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 and subsequent translations kept using the cache after it had been disabled. `deepl cache stats` likewise read a process-local flag that always initialized to enabled, so the status line could never show `disabled` — not even after `deepl config set cache.enabled false`; it now reports the persisted state. -- **sync**: Subcommands now honor `--locale`. Commander bound the flag to the parent `sync` command even when it trailed the subcommand name, so `sync status --locale de` listed every configured locale and `sync export --locale de` emitted XLIFF for all of them. Affects `status`, `validate`, and `export`. -- **sync**: Subcommands now honor `--sync-config`. The flag was silently ignored and the auto-detected `.deepl-sync.yaml` used instead, so `sync validate --sync-config /missing.yaml` exited 0. Affects `status`, `validate`, `export`, `audit`, `resolve`, `push`, and `pull`. -- **sync**: A `--locale` value that is not in `target_locales` now exits with a `ConfigError` (exit 7) naming the offending and configured locales, as documented. Previously `sync --locale ` exited 0 reporting success while translating nothing, which made a typo'd locale in CI report green. -- **sync**: `deepl sync init --sync-config ` now writes the config at that path and checks the already-exists guard against it, instead of always using the current directory. `.deepl-sync.yaml` is also written atomically, so an interrupted run cannot leave a truncated config behind. -- **formats**: Android XML and XLIFF parsing is now linear in file size. Both parsers matched elements with a lazy `([\s\S]*?)` pattern, so every opening tag without a matching close rescanned the rest of the file; a 4 MiB resource file took minutes to parse. Android reconstruct also rescanned the whole file once per dotted key to identify `` members (2.7 s → 63 ms on a 3.6 MiB, 52,000-entry file). -- **formats**: Android XML self-closing elements (``) are no longer deleted together with the element that follows them. -- **formats**: Android XML values whose CDATA body contains `` are no longer truncated on extract, and plural `` attributes are preserved when a translation is written back. -- **formats**: XLIFF files with a CDATA section in a `` between `` and `` are no longer rejected; only CDATA inside `` / `` is unsupported. -- **hooks**: `deepl hooks install` now 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. Previously it reported success while writing a hook git never ran, and crashed with a raw `ENOTDIR` on worktrees and submodules. -- **hooks**: `deepl hooks install` no longer overwrites an existing hook backup. A repeat install writes to the next free `.backup` slot, and the install output now prints the hook path and the backup path. `findGitRoot` also no longer loops forever when given a relative start path. -- **sync**: Auto-glossary sync (`translation.glossary: auto`) skips terms whose source or translation is empty or contains a tab, carriage return, or newline. Such terms were uploaded as corrupted or outright wrong glossary entries, which 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), and a glossary failure for one locale no longer ends glossary sync for the remaining locales — the warning now names the locale, glossary, and cause. -- **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 no longer split into garbage columns. -- **sync**: `deepl sync audit` no longer uses the lock file's source hash as a stand-in for a translation. Divergent translations were reported as consistent and identical ones as an inconsistency displaying a hex hash. Targets that cannot be read are now listed separately as missing — a new additive `missingTargets` field in the `sync audit --format json` output. -- **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 now exit with the network-error code (5) instead of 1, TMS error messages redact credentials embedded in the server URL, and `deepl sync pull` enforces a 32 MiB cap on the response body while reading it, instead of after the whole payload had been parsed. -- **api**: Non-idempotent POST requests are no longer re-submitted after a client-side timeout. Every failure short of a 4xx reached the retry loop for all HTTP methods, so 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 (confirmed with server-side request counts: 4 per timed-out translate and upload), with the worst case being duplicate admin API keys whose secret is returned only once. Automatic retry is now restricted to idempotent methods (GET, HEAD, PUT, DELETE); 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, honoring `Retry-After`. -- **api**: A client-side timeout now exits 5 (network error) instead of 6 (invalid input), matching the documented exit-code contract. Error classification substring-matched the message and missed axios's `timeout of 30000ms exceeded` (`ECONNABORTED` and `ERR_CANCELED` were absent entirely), falling through to `ValidationError` — 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. An HTTP 401 is likewise mapped to `AuthError` (exit 2) instead of falling through to exit 6. -- **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 (measured 125 s before). Honest `Retry-After` waits are not charged against the budget. -- **api**: The Trace ID quoted in an error message now belongs to the request that failed rather than the client's last-seen response, so concurrent requests no longer cross-quote each other's Trace IDs. -- **api**: 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. Error messages also no longer print a doubled `Network error: Network error:` prefix. -- **api**: The document translation result endpoint is never retried — the download is effectively single-use, so a retry after a timeout on a large file could permanently lose an already-billed translation — and document transfers get their own larger timeout. -- **voice**: `deepl voice` now actually reconnects after a transport failure. The socket `error` handler marked the stream ended before the `close` event that always follows arrived, so the reconnect path (up to 3 attempts, `--reconnect` on by default) was unreachable for a real network drop — 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. -- **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 previously emitted paths through aliases that reconstruction could not apply; aliased collections are now translated at their anchor site and the references round-trip untouched. -- **sync**: ICU plural messages using `offset:N` and messages with single-quote-escaped braces (`'{'`, `'}'`, `''` for a literal apostrophe, `'#'` in plural context) are now recognized and preserved instead of falling back to raw machine translation, which demonstrably corrupts ICU keywords and selectors. `sync validate` placeholder checking is also ICU-aware: plural/select branch braces are treated as structure, eliminating spurious `Extra placeholders in translation` warnings for translated branch bodies. -- **sync**: Template literals containing regex metacharacters in scanned source code (e.g. `` t(`item(${i}`) ``) no longer abort context resolution with a raw `SyntaxError` or silently mismatch keys — all metacharacters are escaped before the scan pattern is compiled. -- **watch**: `filesWatched` statistics now report the actual number of files under watch — seeded from the watcher's inventory once the initial scan completes and tracked live on add/unlink. It was previously never incremented and always reported 0. -- **cli**: The global `--timeout`/`--max-retries` flags now also apply to the API-key validation requests made by `deepl init` and `deepl auth set-key`, which previously always used the 30 s default. -- **utils**: Atomic file writes preserve the target's existing permissions instead of resetting them to the umask default — e.g. `deepl write --fix` on a `0600` secrets file no longer leaves it world-readable at `0644`. -- **docs**: README translate examples no longer show a `Translation (XX):` label the CLI never emits; corrected `--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). 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. -- **docs**: TROUBLESHOOTING's exit-code table gains codes 10–12 (SyncDrift, SyncConflict, PartialFailure) and its environment-variable table gains `TMS_API_KEY`, `TMS_TOKEN`, `FORCE_COLOR`, `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 no longer cites Zod (validation is commander `Option.choices()` plus hand-written validators); examples/README no longer references a nonexistent `sample-files/` directory. -- **cli**: Subcommand parse errors (unknown subcommand, unknown option, invalid choice, missing argument) exit 6 (invalid input) as documented, instead of 1 ("CLI crashed"). Top-level parse errors already exited 6; the mapping is now uniform across all subcommands, so CI scripts following the documented exit-code table no longer misread a typo as a crash. -- **usage**: Text and table output no longer report duration-billed products (e.g. speech-to-text minutes) 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 and is unchanged. -- **admin**: An entitlement failure (valid key without admin scope) no longer suggests re-running `deepl init` / `auth set-key` — the suggestion now explains that the admin API requires an administrator API key. Exit code and classification are unchanged. -- **cli**: The non-TTY `--format table` fallback notice carries the documented `WARN` prefix at all six call sites, matching `sync resolve`'s existing convention. -- **glossary**: Scoped commands (`show`, `entries`, `delete`) no longer emit an unrelated org glossary's "empty dictionaries" warning during name resolution — the warning appears only when the glossary actually operated on is affected. -- **sync**: `sync resolve` prints each parse-error fallback warning once (relative path) instead of twice. -- **write**: The unsupported-style error links to the published docs URL instead of a `docs/API.md` path npm users don't have. -- **config**: `config delete` and the config read paths can no longer walk or mutate the prototype chain — `__proto__`/`constructor`/`prototype` segments are rejected, completing the `config set` hardening. +- **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..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..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 ` 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 -- **config**: `ConfigService.save()` writes through an unpredictably named temp file created with an exclusive flag, instead of a fixed `config.json.tmp`. A symlink planted at the predictable path 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 also applied with `chmod` after creation, which the umask cannot widen. -- **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. -- **formats**: The prototype-pollution guard in the JSON parser is now pinned by tests. Replacing `Object.defineProperty` with plain assignment previously left the whole suite green: on a fresh object `obj['__proto__'] = value` retargets that object's prototype rather than `Object.prototype`, so the translation was silently dropped while the negative pollution assertion still passed. -- **ci**: The release workflow refuses to create a GitHub Release when the pushed tag does not match `package.json`. A tag can be pushed at any commit, so without the check a mislabelled tag would mint a Release whose title disagrees with the version it contains. -- **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 placeholder token, every pass re-inserted it and the guard never went false. Measured before the fix: the input `{__VAR_0__}` grew from 9 to 400,009 bytes across 200,000 iterations without converging, and the real function had to be killed. It needed no attacker and no network: `preserveVariables`' pattern matches `{__VAR_0__}` (underscores and digits are in its character class), 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. -- **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 `..`. 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, so it was silent. `include` entries are now rejected at config load for traversal segments and absolute paths (the check the `sync init` wizard already applied, which simply did not exist on the load path), the sweep independently refuses any root outside the project and logs the attempt, the sweep is skipped under `--dry-run`, and its failures are reported instead of discarded. -- **deps**: Resolved four production-dependency advisories flagged by `npm audit --omit=dev` via lockfile-only transitive bumps (no `package.json` ranges changed): `brace-expansion` 5.0.5 → 5.0.6 (GHSA-jxxr-4gwj-5jf2, ReDoS), `form-data` → 4.0.6 (GHSA-hmw2-7cc7-3qxx, CRLF injection), and `ws` 8.20.0 → 8.21.0 (GHSA-58qx-3vcg-4xpx and GHSA-96hv-2xvq-fx4p, uninitialized-memory disclosure and memory-exhaustion DoS). Production `npm audit` is back to zero vulnerabilities, unblocking the CI audit gate. -- **deps**: Resolved two further `brace-expansion` denial-of-service advisories disclosed after the bump above: `brace-expansion` 5.0.6 → 5.0.8 (GHSA-3jxr-9vmj-r5cp, exponential-time expansion of consecutive non-expanding `{}` groups; GHSA-mh99-v99m-4gvg, unbounded expansion length causing an out-of-memory crash). Reached in production through `minimatch`, which accepts `^5.0.5`, so this is again a lockfile-only bump with no `package.json` range changes. Dev-tree instances of the same 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. -- **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 — including merge keys — round-trips as a reference, so alias bombs now parse in milliseconds as plain references instead of being rejected by the interim expansion budget. -- **formats**: Android XML translations containing `]]>` are refused instead of being written into a CDATA section, where they closed the section and had their remainder parsed as XML — injecting elements into generated resource files. This was reachable without a malicious API response, since a failed translation is written through verbatim and the source file is used as the template for a missing target locale. -- **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 also aborts the sync instead of being absorbed, and the bucket pre-read loop (which had no containment check at all) now asserts it too. +- **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 / `` 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 ` 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 `` colliding with `` 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-`; `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 `.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 `` 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. -- **logger**: Credential redaction now recurses through objects, arrays, and `Error` values (with cycle protection) instead of applying only to strings, so a dumped error object can no longer print an `Authorization` header or API key verbatim. -- **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; the two sites now match. -- **config**: `deepl config set` rejects `__proto__`, `constructor`, and `prototype` path segments and resolves keys with `Object.hasOwn`, so crafted paths can no longer pollute `Object.prototype`. -- **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. +- **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 diff --git a/CLAUDE.md b/CLAUDE.md index d0fbecfb..9a57abd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,14 +15,16 @@ DeepL CLI is a command-line interface for the DeepL API that integrates translat ``` CLI Commands (translate, write, voice, sync, watch, glossary, tm, …) ↓ -Service Layer (Translation, Write, Voice, Batch, Watch, Glossary, - TranslationMemory, StyleRules, Admin, Document, - GitHooks, Usage, Detect, Languages) +Service Layer (Translation, Write, Voice, VoiceStreamSession, Batch, Watch, + Glossary, TranslationMemory, StyleRules, Admin, Document, + FileTranslation, StructuredFileTranslation, GitHooks, Usage, + Detect, Languages) ↓ ↓ -Sync Engine (src/sync) Format Parsers (src/formats — 11 i18n formats) +Sync Engine (src/sync — Format Parsers (src/formats — 11 i18n formats) + incl. TmsClient) ↓ ↓ API Client (Translate, Write, Glossary, Document, Voice, - StyleRules, Admin, TMS) + StyleRules, Admin) ↓ Storage (node:sqlite Cache, Config) + Static Data (src/data — language registry) ``` @@ -43,6 +45,11 @@ Storage (node:sqlite Cache, Config) + Static Data (src/data — language registr - **CHANGELOG.md** - Release history and version notes - **docs/API.md** - Complete CLI command reference +- **docs/MIGRATION.md** - 1.x → 2.0.0 upgrade guide; update it with any breaking change +- **docs/SYNC.md** - Continuous-localization reference and `.deepl-sync.yaml` schema +- **docs/TROUBLESHOOTING.md** - Diagnostics for common failures +- **CONTRIBUTING.md** - Contributor workflow and PR checklist +- **SECURITY.md** - Supported versions and vulnerability reporting ## Development Philosophy @@ -77,10 +84,14 @@ Use **Semantic Versioning** with **Conventional Commits**: ### When Cutting a Release -1. Move Unreleased items to `## [X.Y.Z] - YYYY-MM-DD` -2. Set the version with `npm version X.Y.Z --no-git-tag-version` (updates `package.json` and the lockfile together; the release workflow refuses to publish if the tag and `package.json` disagree) -3. Create annotated tag: `git tag -a vX.Y.Z -m "Release vX.Y.Z: "` -4. Push: `git push && git push --tags` +1. Refresh the bundled language lists: `npm run generate:languages` (needs `DEEPL_API_KEY` and a current build). Commit if changed — `npm run check:languages` reports which list drifted without writing. Both come from `GET /v3/languages`: the translation snapshot (`resource=translate_text`) and the Write target list (`resource=write`). Neither list is a hard gate: a well-formed code absent from either defers to the API with a warning, so a language DeepL adds is reachable before a regenerate. What does go stale is `deepl languages` offline, the derived core/regional/extended tiers, and the "Bundled options:" list `write`/`correct` print in their errors. Rebuild after regenerating, since the generator reads the derivation out of `dist/` +2. Move Unreleased items to `## [X.Y.Z] - YYYY-MM-DD` +3. For a MAJOR: update the supported-versions table in `SECURITY.md`, and confirm `docs/MIGRATION.md` covers every **Changed**, **Removed** and action-requiring **Security** entry in the section just dated +4. Set the version with `npm version X.Y.Z --no-git-tag-version` (updates `package.json` and the lockfile together; the release workflow refuses to publish if the tag and `package.json` disagree) +5. Review the tarball: `npm pack --dry-run` — confirm the file list, the version, and that nothing private is in it +6. Create annotated tag: `git tag -a vX.Y.Z -m "Release vX.Y.Z: "` +7. Push to `github` only — `origin` (internal GitLab) autosyncs from it: `git push github main && git push github vX.Y.Z`. Pushing the tag triggers `.github/workflows/release.yml`, which records the GitHub Release and nothing more +8. **Publish to npm separately.** `release.yml` deliberately carries no `npm publish` step or `NPM_TOKEN` — its header says publishing happens from GitLab — so the tag alone does not make the package installable. Run that publish job, then verify from a clean machine: `npm view @deepl/cli version` and `npx @deepl/cli@X.Y.Z --version` ## Code Style @@ -178,6 +189,7 @@ Use conventional commits: - Make separate commits per logical change - Group tests with the logic they test - **Run `npm run lint` and `npm run type-check` before every commit** +- **Never silence a lint warning to get past the gate** — `lint` runs with `--max-warnings 0`, so a warning fails CI. Fix the cause; if a rule genuinely does not apply, scope a disable directive to the code it covers and say why ## Pull Request Guidelines @@ -191,7 +203,9 @@ PR descriptions should include: ## Pre-Commit Checklist - [ ] All tests pass (`npm test`) -- [ ] Linter passes (`npm run lint`) +- [ ] Coverage thresholds hold (`npm run test:coverage`) — this is what CI runs, so a change that passes `npm test` can still fail the build +- [ ] Formatting is clean (`npm run format:check`) — the format-on-commit hook handles this if enabled; see CONTRIBUTING.md +- [ ] Linter passes (`npm run lint`) — warnings fail the build (`--max-warnings 0`) - [ ] TypeScript compiles (`npm run type-check`) - [ ] Unit, integration, and E2E tests written for new features - [ ] HTTP mocking with nock for API interactions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 547317a5..7fd7d6c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ project maintainer. ## Prerequisites -- **Node.js** >= 24.0.0 +- **Node.js** >= 24.15.0 (the release where `node:sqlite` stopped being experimental) - **npm** >= 9.0.0 - A [DeepL API key](https://www.deepl.com/pro-api) (free tier works for development) @@ -36,6 +36,24 @@ npm link deepl --version ``` +### Enable the formatting hook + +CI runs `npm run format:check`, and `.git/hooks` is not version-controlled, so +enable the format-on-commit hook once per clone. It formats only the staged +`src/` and `tests/` TypeScript and re-stages it, so formatting is applied rather +than something you have to remember: + +```bash +cat >> .git/hooks/pre-commit <<'HOOK' +npm run --silent format:staged || exit $? +HOOK +chmod +x .git/hooks/pre-commit +``` + +A partially staged file is reported and skipped rather than reformatted, because +re-adding it would stage the hunks you deliberately left out. Run +`npm run format` yourself in that case. + ## Development Workflow This project follows **Test-Driven Development (TDD)**. Every change goes through the Red-Green-Refactor cycle: @@ -178,8 +196,11 @@ Contributions must be licensed under the same license as the project: 2. **Write tests first**, then implement the feature (TDD). 3. **Run the full check suite** before pushing: ```bash - npm test && npm run lint && npm run type-check && npm run build + npm run format:check && npm run lint && npm run type-check && npm run check-deps && npm run build && npm run test:coverage ``` + These are the same six gates CI runs, in the same order. + `test:coverage` runs the same suite as `npm test` and additionally enforces + the coverage thresholds, which is what CI gates on. 4. **Open a PR** with a clear description covering: - Summary of the change and motivation - List of specific changes @@ -189,7 +210,7 @@ Contributions must be licensed under the same license as the project: ### PR Checklist -- [ ] All tests pass (`npm test`) +- [ ] All tests pass and coverage thresholds hold (`npm run test:coverage`) - [ ] Linter passes (`npm run lint`) - [ ] TypeScript compiles (`npm run type-check`) - [ ] New code includes unit, integration, and e2e tests @@ -197,6 +218,7 @@ Contributions must be licensed under the same license as the project: - [ ] `CHANGELOG.md` updated under **Unreleased** section - [ ] `README.md` updated if user-facing feature changed - [ ] `docs/API.md` updated if command/flag added or changed +- [ ] `docs/MIGRATION.md` updated if the change is breaking or requires action from an existing user - [ ] Example script added/updated in `examples/` for new features - [ ] Added new example script to `examples/run-all.sh` EXAMPLES array (for new CLI commands) diff --git a/README.md b/README.md index c09aba58..37f75a20 100644 --- a/README.md +++ b/README.md @@ -27,25 +27,25 @@ - **🗝️ Admin API** - Organization key management and usage analytics - **🔒 Privacy-First** - Local caching, no telemetry, secure key storage -For security policy and vulnerability reporting, see [SECURITY.md](SECURITY.md). +For security policy and vulnerability reporting, see [SECURITY.md](https://github.com/DeepL/deepl-cli/blob/main/SECURITY.md). ## 📋 Table of Contents - [Installation](#-installation) +- [Upgrading from 1.x](#-upgrading-from-1x) - [Quick Start](#-quick-start) - [Global Options](#-global-options) - [Verbose Mode](#verbose-mode) - [Quiet Mode](#quiet-mode) + - [Command Suggestions](#command-suggestions) - [Custom Configuration Files](#custom-configuration-files) - [Configuration Paths](#configuration-paths) - - [Proxy Configuration](#proxy-configuration) - - [Retry and Timeout Configuration](#retry-and-timeout-configuration) - [Usage](#-usage) - - **Core Commands:** [Translation](#translation) | [Writing Enhancement](#writing-enhancement) | [Voice Translation](#voice-translation) + - **Core Commands:** [Translation](#translation) | [Writing Enhancement](#writing-enhancement) | [Spelling and Grammar Correction](#spelling-and-grammar-correction) | [Voice Translation](#voice-translation) - **Resources:** [Glossaries](#glossaries) | [Translation Memories](#translation-memories) - **Workflow:** [Continuous Localization (sync)](#continuous-localization-deepl-sync) | [Watch Mode](#watch-mode) | [Git Hooks](#git-hooks) - - **Configuration:** [Setup Wizard](#setup-wizard) | [Authentication](#authentication) | [Configure Defaults](#configure-defaults) | [Cache Management](#cache-management) | [Style Rules](#style-rules) - - **Information:** [Usage Statistics](#api-usage-statistics) | [Language Detection](#language-detection) | [Languages](#supported-languages) | [Shell Completion](#shell-completion) | [Command Suggestions](#command-suggestions) + - **Configuration:** [Setup Wizard](#setup-wizard) | [Authentication](#authentication) | [Configure Defaults](#configure-defaults) | [Proxy Configuration](#proxy-configuration) | [Retry and Timeout Configuration](#retry-and-timeout-configuration) | [Cache Management](#cache-management) | [Style Rules](#style-rules) + - **Information:** [Usage Statistics](#api-usage-statistics) | [Language Detection](#language-detection) | [Languages](#supported-languages) | [Shell Completion](#shell-completion) - **Administration:** [Admin API](#admin-api) - [Development](#-development) - [Architecture](#-architecture) @@ -58,7 +58,15 @@ For security policy and vulnerability reporting, see [SECURITY.md](SECURITY.md). ## 📦 Installation -> **Prerequisite:** Node.js **24 or later**. The cache uses Node's built-in [`node:sqlite`](https://nodejs.org/api/sqlite.html) module — no native compilation, no build toolchain needed. +> **Prerequisite:** Node.js **24.15.0 or later**. The cache uses Node's built-in [`node:sqlite`](https://nodejs.org/api/sqlite.html) module — no native compilation, no build toolchain needed. 24.15.0 is the release where `node:sqlite` stopped being experimental; Homebrew installs a suitable Node for you. + +### Homebrew (macOS / Linux) + +```bash +brew install deepl/tap/deepl +``` + +This installs the `deepl` command and everything it needs, including Node. ### npm @@ -71,16 +79,6 @@ deepl --version The package is scoped, but the command is just `deepl`. -### Homebrew (macOS / Linux) - -_Not available yet — the tap ships shortly after the first npm release. Use npm in the meantime._ - -```bash -brew install deepl/tap/deepl -``` - -Once available, this installs the `deepl` command and everything it needs, including Node. - ### From Source ```bash @@ -101,6 +99,14 @@ npm link deepl --version ``` +## 🔀 Upgrading from 1.x + +2.0.0 requires Node 24.15.0, renames the package to `@deepl/cli`, removes several +deprecated flags, and changes a number of exit codes and output shapes that scripts +read. If you have a pipeline built on 1.x, read +**[docs/MIGRATION.md](https://github.com/DeepL/deepl-cli/blob/main/docs/MIGRATION.md)** before upgrading — it lists every +change that needs action, with a before/after for each. + ## 🚀 Quick Start ### 1. Get Your DeepL API Key @@ -170,11 +176,11 @@ $ deepl --verbose translate "Hello" --to es The `--quiet` (or `-q`) flag suppresses all non-essential output, showing only errors and essential results. Perfect for scripts, CI/CD pipelines, and automation. ```bash -# Normal mode - shows informational messages +# Normal mode $ deepl translate "Hello" --to es -Hello +Hola -# Quiet mode - cleaner output +# Quiet mode - same result, without the status lines on stderr $ deepl --quiet translate "Hello" --to es Hola @@ -185,16 +191,19 @@ $ deepl --quiet translate docs/ --to es --output docs-es/ **What's suppressed in quiet mode:** -- ❌ Informational messages (`API Key: ...`) +- ❌ Informational messages (`Note: "zz-zz" is not in the bundled language list; deferring to the API.`) - ❌ Success confirmations (`✓ Cache enabled`) +- ❌ **Warnings** — including the notice that `--api-url` points somewhere that is not a DeepL endpoint, and the `file exceeds 100 KiB, using document API instead` fallback - ❌ Progress spinners and status updates - ❌ Decorative output **What's always shown:** -- ✅ Errors and critical warnings +- ✅ Errors, with their `Suggestion:` line - ✅ Essential command output (translation results, JSON data, statistics) +Warnings are suppressed along with everything else, so run a new pipeline once without `--quiet` before trusting it — that is where you would see a misdirected `--api-url` or a silent fallback to the document API. + **Use cases:** - **CI/CD pipelines**: Clean output for log parsing @@ -212,7 +221,7 @@ deepl --quiet translate docs/ --to es,fr,de --output i18n/ # Returns exit code 0 on success, shows only errors if they occur ``` -See [docs/API.md#global-options](./docs/API.md#global-options) for complete documentation. +See [docs/API.md#global-options](https://github.com/DeepL/deepl-cli/blob/main/docs/API.md#global-options) for complete documentation. ### Command Suggestions @@ -264,7 +273,7 @@ The CLI follows the [XDG Base Directory Specification](https://specifications.fr Existing `~/.deepl-cli/` installations continue to work with no changes needed. -See [docs/API.md#global-options](./docs/API.md#global-options) for more details. +See [docs/API.md#global-options](https://github.com/DeepL/deepl-cli/blob/main/docs/API.md#global-options) for more details. ## 📖 Usage @@ -283,9 +292,9 @@ deepl translate "Bonjour" --from fr --to en # Multiple target languages (each result is prefixed with its language) deepl translate "Good morning" --to es,fr,de -# [ES] Buenos días -# [FR] Bonjour -# [DE] Guten Morgen +# [es] Buenos días +# [fr] Bonjour +# [de] Guten Morgen # Read from stdin echo "Hello world" | deepl translate --to es @@ -325,15 +334,18 @@ Small text-based files (under 100 KiB) automatically use the cached text transla ```bash # Single file translation (uses cache for small text files) deepl translate README.md --to es --output README.es.md -# Translated README.md to 1 language(s): -# [ES] README.es.md +# Translated README.md -> README.es.md + +# Output directory (names the file ..) +deepl translate README.md --to es --output ./translated/ +# Translated README.md -> translated/README.es.md # Multiple target languages (creates README.es.md, README.fr.md, etc.) deepl translate docs.md --to es,fr,de --output ./translated/ -# Translated docs.md to 3 language(s): -# [ES] ./translated/docs.es.md -# [FR] ./translated/docs.fr.md -# [DE] ./translated/docs.de.md +# Translated docs.md to 3 languages: +# [es] ./translated/docs.es.md +# [fr] ./translated/docs.fr.md +# [de] ./translated/docs.de.md # With code preservation (preserves code blocks in markdown) deepl translate tutorial.md --to ja --output tutorial.ja.md --preserve-code @@ -341,8 +353,7 @@ deepl translate tutorial.md --to ja --output tutorial.ja.md --preserve-code # Large text file (over 100 KiB) - automatic fallback with warning deepl translate large-document.txt --to es --output large-document.es.txt # ⚠ File exceeds 100 KiB limit for cached translation (150.5 KiB), using document API instead -# Translated large-document.txt to 1 language(s): -# [ES] large-document.es.txt +# Translated large-document.txt -> large-document.es.txt ``` #### Document Translation @@ -377,6 +388,14 @@ deepl translate contract.pdf --to de --formality more --output contract.de.pdf # Specify source language deepl translate document.pdf --from en --to es --output document.es.pdf +# Apply a glossary (needs a source language: --from, or defaults.sourceLang) +deepl translate report.docx --from en --to de --glossary tech-terms --output report.de.docx + +# Repeat --glossary for up to 5; entries merge, and a term defined in +# several is resolved by the API, not by flag order +deepl translate report.docx --from en --to de --output report.de.docx \ + --glossary base-terms --glossary project-overrides + # Convert PDF to DOCX during translation (ONLY supported conversion) deepl translate document.pdf --to es --output-format docx --output document.es.docx # Translates PDF to Spanish and converts to editable Word format @@ -393,6 +412,7 @@ deepl translate document.pdf --to es --output-format docx --output document.es.d - ✅ **Progress Tracking** - Real-time status updates during translation - ✅ **Large Files** - Handles documents up to 30MB - ✅ **Cost Tracking** - Shows billed characters after translation +- ✅ **Glossaries** - `--glossary` applies to documents too, repeatable up to 5 (needs a source language: `--from`, or `defaults.sourceLang`). Translation memories are not supported for documents. - ✅ **Async Processing** - Documents are translated on DeepL servers with polling **Supported Formats:** @@ -515,9 +535,9 @@ deepl translate "Hello, world!" --to es,fr,de --format table # ┌──────────┬──────────────────────────────────────────────────────────────────────┐ # │ Language │ Translation │ # ├──────────┼──────────────────────────────────────────────────────────────────────┤ -# │ ES │ ¡Hola, mundo! │ -# │ FR │ Bonjour le monde! │ -# │ DE │ Hallo, Welt! │ +# │ es │ ¡Hola, mundo! │ +# │ fr │ Bonjour le monde! │ +# │ de │ Hallo, Welt! │ # └──────────┴──────────────────────────────────────────────────────────────────────┘ # Table format with cost tracking (adds Characters column) @@ -525,19 +545,16 @@ deepl translate "Cost analysis" --to es,fr,de --format table --show-billed-chara # ┌──────────┬────────────────────────────────────────────────────────────┬────────────┐ # │ Language │ Translation │ Characters │ # ├──────────┼────────────────────────────────────────────────────────────┼────────────┤ -# │ ES │ Análisis de costes │ 14 │ -# │ FR │ Analyse des coûts │ 14 │ -# │ DE │ Kostenanalyse │ 14 │ +# │ es │ Análisis de costes │ 14 │ +# │ fr │ Analyse des coûts │ 14 │ +# │ de │ Kostenanalyse │ 14 │ # └──────────┴────────────────────────────────────────────────────────────┴────────────┘ # Preview what would be translated without making API calls (file/directory mode) deepl translate ./docs --to es --dry-run -# Include beta languages that are not yet stable -deepl translate "Hello" --to my --enable-beta-languages - -# Specify tag handling version (v2 improves structure handling, requires --tag-handling) -deepl translate page.html --to es --tag-handling html --tag-handling-version v2 +# Tag handling pins v2 (better structure handling); opt back into v1 explicitly +deepl translate page.html --to es --tag-handling html --tag-handling-version v1 # Advanced XML/HTML tag handling (requires --tag-handling xml) # Control automatic XML structure detection @@ -561,6 +578,8 @@ deepl translate complex.xml --to de --tag-handling xml \ --output complex.de.xml ``` +`--format table` draws the box only when stdout is a terminal. Redirected or captured (`--format table > out.txt`, `$(…)`), it falls back to plain `[lang] text` lines and says so on stderr, so log scrapers and screen readers get parseable output instead of box-drawing characters. Use `--format json` when a script needs a stable shape. + **XML Tag Handling Use Cases:** Advanced XML/HTML tag handling is perfect for: @@ -571,7 +590,7 @@ Advanced XML/HTML tag handling is perfect for: - 🔒 Protecting non-translatable content (scripts, styles, code) - ✂️ Fine-tuned control over sentence splitting for better context -See [examples/09-xml-tag-handling.sh](./examples/09-xml-tag-handling.sh) for comprehensive XML tag handling examples with real-world scenarios. +See [examples/09-xml-tag-handling.sh](https://github.com/DeepL/deepl-cli/blob/main/examples/09-xml-tag-handling.sh) for comprehensive XML tag handling examples with real-world scenarios. **Model Types:** @@ -581,93 +600,113 @@ See [examples/09-xml-tag-handling.sh](./examples/09-xml-tag-handling.sh) for com There is no CLI default: when `--model-type` is omitted, the API server selects the model. -See [examples/08-model-type-selection.sh](./examples/08-model-type-selection.sh) for a complete example with different model types. +See [examples/08-model-type-selection.sh](https://github.com/DeepL/deepl-cli/blob/main/examples/08-model-type-selection.sh) for a complete example with different model types. ### Writing Enhancement Improve your writing with AI-powered grammar, style, and tone suggestions using the **DeepL Write API**. -The `--lang` flag is optional. If omitted, DeepL auto-detects the language and rephrases in the original language. Generic codes `en` and `pt` are also accepted (mapped to `en-US` and `pt-BR` respectively). +The `--lang` flag is optional. If omitted, DeepL auto-detects the language and rephrases in the original language. Generic codes `en` and `pt` are also accepted (mapped to `en-us` and `pt-br` respectively). ```bash # Auto-detect language (--lang is optional) deepl write "This is a sentence." # Specify language explicitly -deepl write "This is a sentence." --lang en-US +deepl write "This is a sentence." --lang en-us -# Use generic language code (en maps to en-US, pt maps to pt-BR) +# Use generic language code (en maps to en-us, pt maps to pt-br) deepl write "This is a sentence." --lang en # Apply business writing style -deepl write "We want to tell you about our new product." --lang en-US --style business +deepl write "We want to tell you about our new product." --lang en-us --style business # Apply academic writing style -deepl write "This shows that the method works." --lang en-US --style academic +deepl write "This shows that the method works." --lang en-us --style academic # Apply casual tone -deepl write "That is interesting." --lang en-US --style casual +deepl write "That is interesting." --lang en-us --style casual # Use confident tone -deepl write "I think this will work." --lang en-US --tone confident +deepl write "I think this will work." --lang en-us --tone confident # Use diplomatic tone -deepl write "Try something else." --lang en-US --tone diplomatic +deepl write "Try something else." --lang en-us --tone diplomatic # Show all alternative improvements deepl write "This is good." --tone enthusiastic --alternatives # Improve files and save to output -deepl write input.txt --lang en-US --output improved.txt +deepl write input.txt --lang en-us --output improved.txt # Edit file in place -deepl write document.md --lang en-US --in-place +deepl write document.md --lang en-us --in-place # Interactive mode - choose from multiple style alternatives # Generates improvements with simple, business, academic, and casual styles -deepl write "Text to improve." --lang en-US --interactive +deepl write "Text to improve." --lang en-us --interactive # Interactive mode with file -deepl write document.md --lang en-US --interactive --in-place +deepl write document.md --lang en-us --interactive --in-place # Interactive mode with specific style (single option) -deepl write "Text to improve." --lang en-US --style business --interactive +deepl write "Text to improve." --lang en-us --style business --interactive # Check if text needs improvement (exit code 0 if no changes needed) -deepl write document.md --lang en-US --check +deepl write document.md --lang en-us --check + +# Machine-readable check result for CI (exit code unchanged) +deepl write document.md --check --format json +# {"ok":true,"mode":"write","needsChanges":true,"changes":3,"file":"/abs/path/document.md"} # Auto-fix files in place -deepl write document.md --lang en-US --fix +deepl write document.md --lang en-us --fix # Auto-fix with backup -deepl write document.md --lang en-US --fix --backup +deepl write document.md --lang en-us --fix --backup # Show diff between original and improved -deepl write file.txt --lang en-US --diff +deepl write file.txt --lang en-us --diff # Show diff for plain text -deepl write "This text could be better." --lang en-US --diff +deepl write "This text could be better." --lang en-us --diff + +# Machine-readable diff (uncoloured patch) +deepl write file.txt --diff --format json +# {"ok":true,"original":"...","improved":"...","diff":"Index: text\n===...\n"} + +# Every alternative the API offered, as an array +deepl write "This is good." --alternatives --format json +# {"ok":true,"original":"This is good.","alternatives":["This is fine.","This works."]} # Bypass cache for this request -deepl write "Fresh improvement please." --lang en-US --no-cache +deepl write "Fresh improvement please." --lang en-us --no-cache ``` +**Failures under `--format json`.** Every command with a JSON mode writes a single error envelope to **stdout** — not stderr — so one redirection captures both outcomes. Discriminate on `ok`: + +```json +{ "ok": false, "error": { "code": "ValidationError", "message": "…", "suggestion": "…" }, "exitCode": 6 } +``` + +The non-zero exit code remains the failure signal, and stderr carries only human-facing warnings. `--check` is not a failure: it emits `ok: true` with a `needsChanges` verdict and exits 0 or 8. + **Supported Languages:** - German (`de`) - English (`en`) - generic, defaults to American English -- English - British (`en-GB`) -- English - American (`en-US`) +- English - British (`en-gb`) +- English - American (`en-us`) - Spanish (`es`) - French (`fr`) - Italian (`it`) - Japanese (`ja`) - Korean (`ko`) - Portuguese (`pt`) - generic, defaults to Brazilian Portuguese -- Portuguese - Brazilian (`pt-BR`) -- Portuguese - European (`pt-PT`) +- Portuguese - Brazilian (`pt-br`) +- Portuguese - European (`pt-pt`) - Chinese (`zh`) - generic, defaults to Simplified Chinese -- Chinese - Simplified (`zh-Hans`) +- Chinese - Simplified (`zh-hans`) **Writing Styles:** @@ -729,7 +768,7 @@ deepl correct document.txt --diff cat notes.txt | deepl correct ``` -`correct` supports the same languages and workflow flags as `write` (`--check`, `--fix`, `--diff`, `--interactive`, `--output`, `--in-place`, `--format json`), but not `--style`/`--tone`. +`correct` supports the same languages and workflow flags as `write` — `--check`, `--fix`, `--backup`, `--diff`, `--alternatives`, `--interactive`, `--output`, `--in-place`, `--no-cache`, `--format json`, and `--to` as an alias of `--lang` — but not `--style`/`--tone`. ### Voice Translation @@ -805,9 +844,15 @@ deepl sync resolve # Optional TMS integration (requires a tms: block in .deepl-sync.yaml) deepl sync push deepl sync pull + +# .deepl-sync.yaml picks the TMS host, so an environment-supplied TMS_API_KEY +# only goes to a host you have approved. Approve one up front: +deepl config set tms.allowedServers tms.example.com ``` -See **[docs/SYNC.md](./docs/SYNC.md)** for configuration (`.deepl-sync.yaml`), the lockfile model, CI/CD recipes, and the TMS REST contract, and [docs/API.md#sync](./docs/API.md#sync) for the full flag reference. +Because `tms.server` is chosen by `.deepl-sync.yaml` in the checkout while `TMS_API_KEY`/`TMS_TOKEN` come from your environment, `push`/`pull` will not send an environment-supplied credential to a hostname you have not approved: it asks once in a terminal (recording the answer in your **user** config, outside the repo) and fails closed with exit 7 under `--no-input` or in CI. Both commands also print the destination origin on success. See [TMS destination trust](https://github.com/DeepL/deepl-cli/blob/main/docs/SYNC.md#tms-destination-trust). + +See **[docs/SYNC.md](https://github.com/DeepL/deepl-cli/blob/main/docs/SYNC.md)** for configuration (`.deepl-sync.yaml`), the lockfile model, CI/CD recipes, and the TMS REST contract, and [docs/API.md#sync](https://github.com/DeepL/deepl-cli/blob/main/docs/API.md#sync) for the full flag reference. ### Watch Mode @@ -851,6 +896,7 @@ deepl watch docs/ --to de --formality more --preserve-code - 💾 Auto-commit to git (optional) - 📌 Git-staged filtering for pre-commit workflows - ⚡ Smart debouncing to avoid redundant translations +- 🗂️ Output mirrors the source tree: a file in a subdirectory of the watched path is written to the matching subdirectory under `--output`, so two same-named files in different directories never overwrite each other **Example output:** @@ -870,7 +916,7 @@ Pattern: *.md Press Ctrl+C to stop ``` -See [examples/16-watch-mode.sh](./examples/16-watch-mode.sh) for a complete watch mode example with multiple scenarios. +See [examples/19-watch-mode.sh](https://github.com/DeepL/deepl-cli/blob/main/examples/19-watch-mode.sh) for a complete watch mode example with multiple scenarios. ### Git Hooks @@ -903,6 +949,7 @@ deepl hooks uninstall pre-commit **Features:** - 🔒 Safe installation with automatic backup of existing hooks +- 🚧 Confirmation before writing an executable outside the working tree — a repository-local `core.hooksPath` travels with the repository, so an install that would land outside the checkout names the configured value and the resolved directory and asks first (`--yes` to accept, exit `6` and nothing written if declined). Paths inside the working tree, linked worktrees, and submodules are unaffected - 🎯 Only validates changed files (pre-commit) - ⚡ Lightweight and fast - 🔧 Customizable hook scripts @@ -916,14 +963,22 @@ $ deepl hooks list Git Hooks Status: ✓ pre-commit installed - ✗ pre-push not installed - ✗ commit-msg not installed + ! pre-push installed, content does not match its recorded hash + ? commit-msg installed, no hash recorded (legacy marker) ✗ post-commit not installed + +The content of a hook no longer matches the hash its marker records. That +is expected if you edited the hook yourself. If you did not, replace it: + deepl hooks install +A recorded hash detects a change made after the marker was written. It +cannot establish that a hook came from this CLI. ``` -**Note:** The hooks are generated with placeholder validation logic. You can customize them based on your project's translation workflow by editing the hook files directly at `.git/hooks/pre-commit` or `.git/hooks/pre-push`. +Every hook this CLI installs carries a marker recording the SHA-256 of its body, and `hooks list` checks it rather than reporting a bare "installed". A `!` means the file at that path is not what its own marker says it is — you edited it, or something else wrote it. The hash is unkeyed, so a match shows a hook has not changed since its marker was written, not that this CLI wrote it: anyone who can write the hook can write a marker to agree with it. `--format json` reports these states as strings (`installed`, `modified`, `unverified`, `not-installed`) rather than booleans. + +**Note:** The hooks are generated with placeholder validation logic. You can customize them based on your project's translation workflow by editing the hook files directly at `.git/hooks/pre-commit` or `.git/hooks/pre-push`. A customized hook reports as `!` from then on, since its body no longer matches the hash recorded when it was installed. -See [examples/17-git-hooks.sh](./examples/17-git-hooks.sh) for a complete git hooks example demonstrating installation, usage, and management. +See [examples/20-git-hooks.sh](https://github.com/DeepL/deepl-cli/blob/main/examples/20-git-hooks.sh) for a complete git hooks example demonstrating installation, usage, and management. ### Configuration @@ -944,15 +999,21 @@ deepl init #### Authentication ```bash -# Set API key -deepl auth set-key YOUR_API_KEY +# Set the API key (piping keeps it out of process listings and shell history) +echo "YOUR_API_KEY" | deepl auth set-key --from-stdin # ✓ API key saved and validated successfully -# Account type: DeepL API Free -# Show current API key status +# Store it without validating against the API (offline or proxied networks) +echo "YOUR_API_KEY" | deepl auth set-key --from-stdin --no-verify +# ✓ API key saved without validation + +# Passing the key as an argument still works, but is deprecated and warns — +# other users can read it via `ps` +deepl auth set-key YOUR_API_KEY + +# Show the stored key, masked (does not contact the API) deepl auth show # API Key: abc1...2def -# Status: Valid # Clear API key deepl auth clear @@ -979,13 +1040,13 @@ deepl usage **Note:** Usage statistics help you track your DeepL API character quota and avoid exceeding limits. -See [examples/23-usage-monitoring.sh](./examples/23-usage-monitoring.sh) for a complete usage monitoring example. +See [examples/33-usage-monitoring.sh](https://github.com/DeepL/deepl-cli/blob/main/examples/33-usage-monitoring.sh) for a complete usage monitoring example. **Cost Transparency:** For detailed cost tracking per translation, use the `--show-billed-characters` flag with the translate command (see Advanced Translation Options above). This displays the actual billed character count for each translation, helping with budget planning and cost analysis. -See [examples/12-cost-transparency.sh](./examples/12-cost-transparency.sh) for comprehensive cost tracking examples. +See [examples/12-cost-transparency.sh](https://github.com/DeepL/deepl-cli/blob/main/examples/12-cost-transparency.sh) for comprehensive cost tracking examples. #### Language Detection @@ -1001,12 +1062,15 @@ echo "こんにちは" | deepl detect # JSON output for scripting deepl detect "Hola mundo" --format json -# { "detected_language": "es", "language_name": "Spanish" } +# { +# "detected_language": "es", +# "language_name": "Spanish" +# } ``` #### Supported Languages -List all 121 supported languages grouped by category: +List all 125 supported languages grouped by category: ```bash # Show all supported languages (both source and target) @@ -1039,6 +1103,22 @@ deepl languages --source # Show only target languages deepl languages --target +# Show which features each language supports +deepl languages --target --features +# Target Languages: +# de German — formality, glossary, style rules, translation memory, auto detection +# pt Portuguese — formality, glossary, auto detection +# en-gb English (British) — glossary, style rules, translation memory +# ... +# Extended Languages (quality_optimized only, no formality/glossary): +# th Thai — style rules, translation memory, auto detection +# ... +# +# All listed languages also support: tag handling. + +# The same matrix as columns +deepl languages --target --features --format table + # Works without API key (shows local registry data) deepl languages ``` @@ -1046,10 +1126,14 @@ deepl languages **Note:** Languages are grouped into three categories: - **Core** (32) — Full feature support including formality and glossaries -- **Regional** (7) — Target-only variants: `en-gb`, `en-us`, `es-419`, `pt-br`, `pt-pt`, `zh-hans`, `zh-hant` +- **Regional** (11) — Target-only variants: `de-ch`, `de-de`, `en-gb`, `en-us`, `es-419`, `fr-ca`, `fr-fr`, `pt-br`, `pt-pt`, `zh-hans`, `zh-hant` - **Extended** (82) — Only support `quality_optimized` model, no formality or glossary -See [examples/24-languages.sh](./examples/24-languages.sh) for a complete example. +`--features` is finer-grained than these tiers — some extended languages do support style rules and translation memory. It needs an API key, since the local registry carries no feature data. A feature only gets its own column when support differs across the languages listed; one supported by all of them is summarised on the last line instead of repeated on every row. + +**The API decides what exists.** `GET /v3/languages` is authoritative; the bundled list is a generated snapshot of it (`npm run generate:languages`) so that listing and validating languages works offline. Because a snapshot can lag the API, a well-formed language code it does not list is passed to the API rather than rejected locally — that is why `deepl translate --to de-CH` works even if your copy of the list predates Swiss German. Input that is not shaped like a language tag is still rejected immediately. + +See [examples/34-languages.sh](https://github.com/DeepL/deepl-cli/blob/main/examples/34-languages.sh) for a complete example. #### Configure Defaults @@ -1071,11 +1155,15 @@ deepl config get cache.enabled # Set a value deepl config set defaults.targetLangs es,fr,de -# ✓ Configuration updated: defaults.targetLangs = ["es","fr","de"] +# ✓ Set defaults.targetLangs = es,fr,de + +# Approve TMS destinations for an environment-supplied TMS_API_KEY / TMS_TOKEN +deepl config set tms.allowedServers tms.example.com,tms2.example.com +# ✓ Set tms.allowedServers = tms.example.com,tms2.example.com # Set cache size (in bytes) deepl config set cache.maxSize 2147483648 -# ✓ Configuration updated: cache.maxSize = 2147483648 +# ✓ Set cache.maxSize = 2147483648 # Disable caching deepl config set cache.enabled false @@ -1177,11 +1265,14 @@ DeepL glossaries ensure consistent terminology across translations. The v3 Gloss # File format: source_termtarget_term per line echo -e "API\tAPI\nREST\tREST\nauthentication\tAuthentifizierung" > glossary.tsv deepl glossary create tech-terms en de glossary.tsv -# ✓ Glossary created: tech-terms (ID: abc123...) -# Source language: EN -# Target languages: DE +# ✓ Glossary created successfully +# Name: tech-terms +# ID: abc123... +# Source language: en +# Target languages: de # Type: Single target # Total entries: 3 +# Created: 2026-08-09T12:34:56.000Z # List all glossaries deepl glossary list @@ -1196,7 +1287,7 @@ deepl glossary show tech-terms # Target languages: de # Type: Single target # Total entries: 3 -# Created: 2024-10-07T12:34:56Z +# Created: 2026-08-09T12:34:56.000Z # Show glossary entries (single-target glossary - no --target flag needed) deepl glossary entries tech-terms @@ -1204,15 +1295,15 @@ deepl glossary entries tech-terms # REST → REST # authentication → Authentifizierung -# Show entries for multilingual glossary (--target flag required) +# Show entries for multilingual glossary (--target-lang required) deepl glossary entries multilingual-terms --target-lang es # API → API # cache → caché # ... -# Delete glossary -deepl glossary delete tech-terms -# ✓ Glossary deleted: tech-terms +# Delete glossary (prompts for confirmation; --yes to skip) +deepl glossary delete tech-terms --yes +# ✓ Glossary deleted successfully # Preview what would be deleted without performing the operation deepl glossary delete tech-terms --dry-run @@ -1277,12 +1368,18 @@ authentication Authentifizierung **Key Features:** -- **Single-target glossaries** - One source language → one target language (e.g., EN → DE) -- **Multilingual glossaries** - One source language → multiple target languages (e.g., EN → ES, FR, DE) +- **Single-target glossaries** - One source language → one target language (e.g., en → de) +- **Multilingual glossaries** - One source language → multiple target languages (e.g., en → es, fr, de) - **Direct updates** - v3 API uses PATCH endpoints for efficient updates (no delete+recreate) -- **Smart defaults** - `--target` flag only required for multilingual glossaries +- **Smart defaults** - `--target-lang` only required for multilingual glossaries - **Visual indicators** - 📖 for single-target, 📚 for multilingual glossaries -- **Translation integration** - Use `--glossary` flag in translate and watch commands to apply glossary terms +- **Translation integration** - Use `--glossary` flag in translate and watch commands to apply glossary terms (a source language is required, since the API rejects a glossary without one: pass `--from`, or set `defaults.sourceLang`) +- **Several glossaries at once** - Repeat `--glossary` on `translate` for up to 5 glossaries; entries are merged, and a term defined in more than one is resolved by the API, not by flag order + +```bash +# Combine shared base terminology with project-specific terms +deepl translate "Hello world" --from en --to de --glossary base-terms --glossary project-overrides +``` ### Translation Memories @@ -1291,8 +1388,8 @@ Reuse approved translations from your account's translation memories. TMs are au ```bash # List translation memories on the account deepl tm list -# brand-terms (EN → DE, FR, JA) -# legal-phrases (EN → FR) +# brand-terms (en → de, fr, ja) +# legal-phrases (en → fr) # JSON output for scripting deepl tm list --format json @@ -1304,7 +1401,7 @@ deepl translate "Hello" --to de --translation-memory brand-terms deepl translate "Hello" --to de --translation-memory brand-terms --tm-threshold 90 ``` -For sync runs, configure `translation.translation_memory` (and optionally `translation.translation_memory_threshold`) in `.deepl-sync.yaml` — see [docs/SYNC.md](./docs/SYNC.md). +For sync runs, configure `translation.translation_memory` (and optionally `translation.translation_memory_threshold`) in `.deepl-sync.yaml` — see [docs/SYNC.md](https://github.com/DeepL/deepl-cli/blob/main/docs/SYNC.md). ### Style Rules @@ -1343,7 +1440,7 @@ deepl style-rules add-instruction sr-abc123 tone "Be formal" deepl translate "Hello" --to de --style-id "abc-123-def-456" ``` -See [docs/API.md#style-rules](./docs/API.md#style-rules) for the full subcommand reference, including the configured-rules JSON shape. +See [docs/API.md#style-rules](https://github.com/DeepL/deepl-cli/blob/main/docs/API.md#style-rules) for the full subcommand reference, including the configured-rules JSON shape. ### Admin API @@ -1462,6 +1559,8 @@ deepl cache disable Cache location: `~/.cache/deepl-cli/cache.db` (or `~/.deepl-cli/cache.db` for legacy installations) +One database is shared by every endpoint a config directory has ever talked to, so the resolved API base URL is part of every translation, `write`, and `correct` cache key. A run against `--api-url http://localhost:1234` gets its own entries and can never answer a later request aimed at `api.deepl.com`; the free and Pro endpoints are likewise keyed apart. + ## 💻 Development ### Prerequisites @@ -1501,7 +1600,7 @@ npm run build ### Development Workflow -See [CLAUDE.md](./CLAUDE.md) for comprehensive development guidelines. +See [CLAUDE.md](https://github.com/DeepL/deepl-cli/blob/main/CLAUDE.md) for comprehensive development guidelines. ### Project Structure @@ -1549,12 +1648,13 @@ Service Layer (Translation, Write, Voice, Batch, Watch, Glossary, TranslationMemory, StyleRules, Admin, Document, GitHooks, Usage, Detect, Languages) ↓ ↓ -Sync Engine (src/sync) Format Parsers (src/formats — 11 i18n formats) +Sync Engine (src/sync — Format Parsers (src/formats — 11 i18n formats) + incl. TmsClient) ↓ ↓ API Client (Translate, Write, Glossary, Document, Voice, - StyleRules, Admin, TMS) + StyleRules, Admin) ↓ -Storage (SQLite Cache, Config) + Static Data (src/data — language registry) +Storage (node:sqlite Cache, Config) + Static Data (src/data — language registry) ``` ### Key Components @@ -1568,7 +1668,7 @@ Storage (SQLite Cache, Config) + Static Data (src/data — language registry) - **Watch Service** — file watching with debouncing - **Glossary Service** — glossary management and application - **Translation Memory Service** — reuse approved translations (`--translation-memory`) -- **Cache** — SQLite cache, oldest-entry-first eviction (`src/storage/cache.ts`) +- **Cache** — `node:sqlite` cache, oldest-entry-first eviction (`src/storage/cache.ts`) - **Preservation utilities** — `src/utils/` helpers for code blocks, variables, and ICU MessageFormat ## 🧪 Testing @@ -1600,12 +1700,13 @@ npm run examples:fast ## 📚 Documentation -- **[API Reference](./docs/API.md)** - Complete API reference with all commands, flags, and options -- **[Sync Guide](./docs/SYNC.md)** - Continuous localization: `.deepl-sync.yaml` configuration, lockfile model, CI/CD recipes, TMS integration -- **[Troubleshooting](./docs/TROUBLESHOOTING.md)** - Common issues, solutions, and exit codes reference -- **[Examples](./examples/README.md)** - Practical usage examples for every feature -- **[Changelog](./CHANGELOG.md)** - Release history and version notes -- **[Development Guidelines](./CLAUDE.md)** - TDD workflow and contribution standards +- **[API Reference](https://github.com/DeepL/deepl-cli/blob/main/docs/API.md)** - Complete API reference with all commands, flags, and options +- **[Migrating from 1.x](https://github.com/DeepL/deepl-cli/blob/main/docs/MIGRATION.md)** - Removed flags, changed exit codes, and output that scripts parse +- **[Sync Guide](https://github.com/DeepL/deepl-cli/blob/main/docs/SYNC.md)** - Continuous localization: `.deepl-sync.yaml` configuration, lockfile model, CI/CD recipes, TMS integration +- **[Troubleshooting](https://github.com/DeepL/deepl-cli/blob/main/docs/TROUBLESHOOTING.md)** - Common issues, solutions, and exit codes reference +- **[Examples](https://github.com/DeepL/deepl-cli/blob/main/examples/README.md)** - Practical usage examples for every feature +- **[Changelog](https://github.com/DeepL/deepl-cli/blob/main/CHANGELOG.md)** - Release history and version notes +- **[Development Guidelines](https://github.com/DeepL/deepl-cli/blob/main/CLAUDE.md)** - TDD workflow and contribution standards - **[DeepL API Docs](https://www.deepl.com/docs-api)** - Official API documentation - **[CLI Guidelines](https://clig.dev/)** - Command-line best practices @@ -1625,11 +1726,12 @@ npm run examples:fast | `FORCE_COLOR` | Force colored output even when terminal doesn't support it. Useful in CI. `NO_COLOR` takes priority if both are set. | | `TERM=dumb` | Disables colored output and progress spinners. Automatically set by some CI environments and editors. | -See [docs/API.md#environment-variables](./docs/API.md#environment-variables) for full details. +See [docs/API.md#environment-variables](https://github.com/DeepL/deepl-cli/blob/main/docs/API.md#environment-variables) for full details. ## 🔒 Security & Privacy -- **API key storage** - Keys are stored as **plaintext** in `config.json` with `0600` file permissions (owner read/write only). For CI/CD or shared environments, prefer the `DEEPL_API_KEY` environment variable instead. Avoid committing `config.json` to version control — it is gitignored by default. +- **API key storage** - Keys are stored as **plaintext** in `config.json` with `0600` file permissions (owner read/write only). That mode is checked on every load, not only when the file is created: a `config.json` that arrives from a backup, an `rsync`, or another tool with a looser mode is tightened back to `0600` and the run says so, since the key may already have been readable. The containing directory is created `0700`; one that already existed and lets other users **write** to it is reported with the `chmod` that would close it, but is not changed — the CLI does not lock other users out of a directory it did not create (a sticky directory such as `/tmp` is exempt, since only a file's owner can replace it there). For CI/CD or shared environments, prefer the `DEEPL_API_KEY` environment variable instead. Avoid committing `config.json` to version control — it is gitignored by default. +- **Credential redaction in diagnostics** - Errors, warnings and `--verbose` output are scrubbed before they reach stderr: auth headers and `token=`/`api_key=` query parameters are replaced by `[REDACTED]`, as is the literal value of whichever credential the run is using, whether it came from the environment, `config.json`, or `.deepl-sync.yaml`. A credential value shorter than 8 characters is matched only by the header and query-parameter patterns, not by its literal value — a substring that short corrupts ordinary words in the diagnostics without protecting anything real. `deepl translate` output on stdout is never rewritten. - **Local caching** - All cached data stored locally in SQLite, never shared - **No telemetry** - Zero usage tracking or data collection - **Environment variable support** - Use `DEEPL_API_KEY` environment variable for CI/CD @@ -1637,7 +1739,7 @@ See [docs/API.md#environment-variables](./docs/API.md#environment-variables) for ## 🤝 Contributing -Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow (strict TDD), test requirements, commit conventions, and the pull request process. +Contributions are welcome! See [CONTRIBUTING.md](https://github.com/DeepL/deepl-cli/blob/main/CONTRIBUTING.md) for the development workflow (strict TDD), test requirements, commit conventions, and the pull request process. ## 📄 License diff --git a/docs/API.md b/docs/API.md index b8995b50..8d0ec78d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,7 +1,7 @@ # DeepL CLI - API Reference **Version**: 2.0.0 -**Last Updated**: July 29, 2026 +**Last Updated**: August 9, 2026 Complete reference for all DeepL CLI commands, options, and configuration. @@ -37,6 +37,7 @@ Complete reference for all DeepL CLI commands, options, and configuration. - Administration - [admin](#admin) - [Configuration](#configuration) +- [Terminal Output Safety](#terminal-output-safety) - [Environment Variables](#environment-variables) - [Exit Codes](#exit-codes) @@ -59,6 +60,8 @@ Options that work with all commands: Automatic retries apply to idempotent requests, and to rate-limited (429) responses for every method; a request that submits work is otherwise never replayed. Retries share a wall-clock budget of twice `--timeout`, so raising `--max-retries` alone does not extend how long the CLI waits on an unresponsive endpoint. +`--timeout` is an enforced wall-clock deadline per attempt, not merely a socket inactivity timeout: an endpoint that keeps a response body trickling is aborted at the deadline just like one that never answers at all. Responses are additionally capped at 32 MiB, raised to 128 MiB for the document download, which must accommodate a whole translated file. Exceeding either bound is a `NetworkError` (exit code 5); a size-cap rejection is deterministic and is never retried. + **Examples:** ```bash @@ -186,7 +189,7 @@ Commands are organized into six groups, matching the `deepl --help` output: | Group | Commands | Description | | ------------------ | ------------------------------------------------ | ------------------------------------------------------------------------- | -| **Core Commands** | `translate`, `write`, `voice` | Translation, writing enhancement, and speech translation | +| **Core Commands** | `translate`, `write`, `correct`, `voice` | Translation, writing enhancement, spelling and grammar correction, and speech translation | | **Resources** | `glossary`, `tm` | Manage translation glossaries and translation memory | | **Workflow** | `watch`, `sync`, `hooks` | File watching, project sync, and git hook automation | | **Configuration** | `init`, `auth`, `config`, `cache`, `style-rules` | Setup wizard, authentication, settings, caching, and style rules | @@ -232,7 +235,7 @@ Translate text directly, from stdin, from files, or entire directories. Supports **Output Options:** -- `--output, -o PATH` - Output file or directory (required for file/directory translation, optional for text). Use `-` for stdout (text-based files only) +- `--output, -o PATH` - Output file or directory (required for file/directory translation, optional for text). A directory receives `..` — `deepl translate README.md --to es --output docs/` writes `docs/README.es.md`, and a trailing slash creates the directory if it does not exist. Use `-` for stdout (text-based files only) - `--output-format FORMAT` - Convert PDF to DOCX during translation. Valid choices: `docx` (only supported conversion) - `--enable-minification` - Enable document minification for PPTX/DOCX files (reduces file size) - `--format FORMAT` - Output format: `text`, `json`, `table` (default: `text`) @@ -250,21 +253,22 @@ Translate text directly, from stdin, from files, or entire directories. Supports - `--splitting-tags TAGS` - Comma-separated XML tags that split sentences (requires `--tag-handling xml`) - `--non-splitting-tags TAGS` - Comma-separated XML tags that should not be used to split sentences (requires `--tag-handling xml`) - `--ignore-tags TAGS` - Comma-separated XML tags with content to ignore (requires `--tag-handling xml`) -- `--tag-handling-version VERSION` - Tag handling version: `v1`, `v2`. v2 improves XML/HTML structure handling (requires `--tag-handling`) -- `--glossary NAME-OR-ID` - Use glossary by name or ID for consistent terminology +- `--tag-handling-version VERSION` - Tag handling version: `v1`, `v2`. v2 improves XML/HTML structure handling (requires `--tag-handling`). **Defaults to `v2`**, sent explicitly on every `--tag-handling` request rather than left to the API's own default, which is documented as moving from v1 to v2 at some point — pinning keeps output from shifting on DeepL's timetable. Pass `--tag-handling-version v1` for the older behaviour, which DeepL documents as heading for deprecation +- `--glossary NAME-OR-ID` - Use glossary by name or ID for consistent terminology. Repeatable, up to 5 per request; entries are merged, so terms unique to each glossary all apply. When several glossaries define the same source term, which mapping wins is the API's choice and does not follow flag order, so position is not a way to override a term. Passing a 6th exits 6 (ValidationError). A source language is required, because the API rejects a glossary without one: supply `--from`, or set `defaults.sourceLang` and it is used automatically. With neither, the command exits 6 before any request. - `--translation-memory NAME-OR-UUID` - Use translation memory by name or UUID (forces `quality_optimized` model). Requires `--from` because TMs are pinned to a specific source→target language pair. Invalid use exits 6 (ValidationError); unresolvable/misconfigured TM exits 7 (ConfigError). - `--tm-threshold N` - Minimum match score 0–100 (default 75, requires `--translation-memory`). Invalid use exits 6 (ValidationError); unresolvable/misconfigured TM exits 7 (ConfigError). - `--custom-instruction INSTRUCTION` - Custom instruction for translation (repeatable, max 10, max 300 chars each). Forces `quality_optimized` model. Cannot be used with `latency_optimized`. - `--style-id UUID` - Style rule ID for translation (Pro API only). Forces `quality_optimized` model. Cannot be used with `latency_optimized`. Use `deepl style-rules list` to see available IDs. -- `--enable-beta-languages` - Include beta languages that are not yet stable (forward-compatibility with new DeepL languages) - `--no-cache` - Bypass cache for this translation (useful for testing/forcing fresh translation) - `--dry-run` - Show what would be translated without performing the operation **API Options:** -- `--api-url URL` - Custom API endpoint URL (for testing or private instances) +- `--api-url URL` - Custom API endpoint URL (for testing or private instances). Any host other than `api.deepl.com` and `api-free.deepl.com` prints a warning to **stderr** naming the origin the key is about to be sent to — see "Non-standard endpoint notice" below - `--show-billed-characters` - Request and display actual billed character count for cost transparency +**Non-standard endpoint notice.** The API key is attached to every request, so whatever host the CLI is pointed at receives it along with the text being translated. Whenever the resolved endpoint is not one of the two standard DeepL hosts, the CLI prints an unconditional warning to stderr naming the **origin** (scheme, host, port — never a path or query, which the redirect may control) and stating where the redirect came from: `set by --api-url`, or `set by api.baseUrl in ` with the actual file path, since several config locations are searched and `-c` can override them. Regional endpoints such as `api-jp.deepl.com` are included, as is loopback — a co-tenant process listening on `127.0.0.1` receives the key just as a remote host does, the same reasoning the TMS destination-trust gate applies. The notice is **not** gated behind `--verbose`, because it is the user's only signal that a substituted config file has redirected their key; it is suppressed by `--quiet` like every other warning, and it goes to stderr so `deepl translate ... > out.txt` still captures only translation output. It is printed once per run, not once per API client. Under `--verbose`, each request line also names the resolved origin (`[verbose] HTTP POST https://api.deepl.com/v2/translate completed in 12ms (status 200)`). + **Batch Options (for directories):** - `--no-recursive` - Do not recurse into subdirectories (recursive is the default) @@ -351,6 +355,10 @@ The following structured formats are parsed to extract only string values, trans - `.json` - JSON files (i18n locale files, config files) - `.yaml`, `.yml` - YAML files (Rails i18n, config files) +An empty string value is copied to the output unchanged and is never sent to the API, so a placeholder key you have not written copy for yet costs nothing and stays in the file. Requests are capped at 50 strings each; if one of them fails, no output file is written and the command exits with that failure's own code (for example 3 for a rate limit), because a partly translated file is indistinguishable from a complete one. + +**Size ceiling: 10 MiB.** A structured file above that is refused with a `ValidationError` (exit 6) naming its size, before it is read, and a directory run fails just that file. The limit is lower than the 30 MB document ceiling because these two routes cost very different amounts of memory: a document is streamed to the API once, while a structured file is parsed into an object graph — roughly 7-13x its size resident — and the multi-target path (`--to de,fr,es`) builds a fresh copy per language, up to 5 at a time. 10 MiB matches the ceiling `sync.limits.max_file_bytes` can never be configured above, so nothing [`deepl sync`](SYNC.md) accepts is refused here. For anything larger, split the file or use `deepl sync`, which walks a locale directory file by file and translates only the keys that changed. + ```bash # Translate JSON locale file deepl translate en.json --to es --output es.json @@ -411,6 +419,14 @@ deepl translate document.pdf --to es --output document.es.docx --output-format d # Enable document minification for smaller file size (PPTX/DOCX only) deepl translate presentation.pptx --to de --output presentation.de.pptx --enable-minification deepl translate report.docx --to fr --output report.fr.docx --enable-minification + +# Apply a glossary (--from is required for document glossaries) +deepl translate report.docx --from en --to de --output report.de.docx --glossary tech-terms + +# Repeat --glossary for up to 5; entries merge, and a term defined in +# several is resolved by the API, not by flag order +deepl translate report.docx --from en --to de --output report.de.docx \ + --glossary base-terms --glossary project-overrides ``` **Supported Document Formats:** @@ -441,6 +457,7 @@ deepl translate report.docx --to fr --output report.fr.docx --enable-minificatio - Large documents may take several seconds to translate - Maximum file sizes: 30MB (document API, all formats), 100 KiB (cached text API) - **Document minification** (`--enable-minification`): Reduces file size for PPTX and DOCX files only. Useful for large presentations and documents. +- **Glossaries**: `--glossary` applies to documents and is repeatable up to 5, resolved exactly as for text translation — entries are merged, and when several glossaries define the same source term which mapping wins is the API's choice and does not follow flag order. A source language is required — the API rejects a document glossary without one ("source_lang has to be specified in order to use a glossary") — so pass `--from`, or set `defaults.sourceLang` and it is used automatically. Glossary matching is context-dependent exactly as it is for text: a term may be applied in one sentence and left alone in another, and a bare newline-separated word list often gets few terms applied. `--translation-memory` remains unsupported for documents. **Directory translation:** @@ -537,12 +554,28 @@ deepl translate complex.xml --to de --tag-handling xml \ ```bash # Use glossary for consistent terminology -deepl translate "API documentation" --to es --glossary tech-terms +deepl translate "API documentation" --from en --to es --glossary tech-terms # Use glossary by ID -deepl translate README.md --to fr --glossary abc-123-def-456 --output README.fr.md +deepl translate README.md --from en --to fr --glossary abc-123-def-456 --output README.fr.md +``` + +**Multiple glossaries on one request:** + +Repeat `--glossary` to apply up to 5 glossaries to a single request. Their entries are merged, so terms unique to each glossary all apply. When more than one glossary defines the same source term, **which mapping wins is the API's choice and does not follow flag order** — position is not a way to override a term, so avoid relying on one glossary to shadow another. The order is still sent as given and never sorted, because it is part of the cache key: reordering the flags is a different request with its own cache entry. Names and UUIDs can be mixed; each value is resolved independently. A 6th `--glossary` exits 6 (ValidationError). A name that cannot be resolved — unknown, ambiguous, or covering a different language pair than the one requested — exits 7 (ConfigError) without sending a translation request. + +```bash +# Shared base terminology combined with project-specific terms; terms unique +# to each apply, and a term defined in both is resolved by the API +deepl translate "Hello world" --from en --to de --glossary base-terms --glossary project-overrides + +# Names and UUIDs can be mixed +deepl translate README.md --from en --to fr --output README.fr.md \ + --glossary abc-123-def-456 --glossary house-style ``` +A single `--glossary` is still sent as the API's `glossary_id`, so existing commands and their cached results are unaffected. + **Translation memory usage:** Translation memories (TMs) are pinned to a source→target language pair, so `--from` is required. Passing `--translation-memory` forces `quality_optimized` model type; combining it with `--model-type latency_optimized` (or `prefer_quality_optimized`) exits 6 (ValidationError). TM files are authored and uploaded via the DeepL web UI; this CLI resolves the name-or-UUID against `GET /v3/translation_memories` and caches the resolution per run. @@ -562,7 +595,17 @@ deepl translate "Welcome to our product." --from en --to de \ **Multi-target file translation with glossary / TM:** -Both `--glossary` and `--translation-memory` apply to multi-target file translation (e.g. `--to en,fr,es`) and in that mode `--from` is required. Glossary name resolution works transparently across all target languages. Translation memory name resolution, however, requires a single TM that covers every requested target language pair — because each TM in DeepL is scoped to one source→target pair, using a TM name with differing multi-targets surfaces a `ConfigError` (exit 7). For multi-target TM use, pass the TM UUID directly. +Both `--glossary` and `--translation-memory` apply to multi-target file translation (e.g. `--to en,fr,es`) and in that mode `--from` is required. Resolving either **by name** checks that the resource covers every requested language pair before any translation request goes out, and exits 7 (`ConfigError`) naming what it does cover if not: + +```bash +deepl translate "hello" --from en --to de --glossary "EN-ES Test Glossary" +# Error: Glossary "EN-ES Test Glossary" does not support the requested language pair +# Suggestion: Glossary covers en→es; requested en→de. +``` + +A multilingual glossary satisfies this when it holds a dictionary for each requested pair; matching is per dictionary, so a glossary holding en→es and de→fr does not count as covering en→fr. Each translation memory in DeepL is scoped to one source→target pair, so a TM name with differing multi-targets cannot satisfy it at all — pass the TM UUID for multi-target TM use. + +Passing a **UUID** skips the check and lets the API decide, which is the escape hatch if the check is ever wrong: the API answers with `No dictionary found for language pair EN-DE in glossary `. ```bash # Glossary across multiple targets (name resolution works for all targets) @@ -630,9 +673,9 @@ deepl translate "Hello, world!" --to es,fr,de --format table # ┌──────────┬──────────────────────────────────────────────────────────────────────┐ # │ Language │ Translation │ # ├──────────┼──────────────────────────────────────────────────────────────────────┤ -# │ ES │ ¡Hola mundo! │ -# │ FR │ Bonjour le monde! │ -# │ DE │ Hallo Welt! │ +# │ es │ ¡Hola mundo! │ +# │ fr │ Bonjour le monde! │ +# │ de │ Hallo Welt! │ # └──────────┴──────────────────────────────────────────────────────────────────────┘ # Add --show-billed-characters to display the Characters column @@ -640,9 +683,9 @@ deepl translate "Cost tracking" --to es,fr,de --format table --show-billed-chara # ┌──────────┬────────────────────────────────────────────────────────────────┬────────────┐ # │ Language │ Translation │ Characters │ # ├──────────┼────────────────────────────────────────────────────────────────┼────────────┤ -# │ ES │ Seguimiento de costes │ 16 │ -# │ FR │ Suivi des coûts │ 16 │ -# │ DE │ Kostenverfolgung │ 16 │ +# │ es │ Seguimiento de costes │ 16 │ +# │ fr │ Suivi des coûts │ 16 │ +# │ de │ Kostenverfolgung │ 16 │ # └──────────┴────────────────────────────────────────────────────────────────┴────────────┘ # Long translations automatically wrap in the Translation column @@ -661,7 +704,7 @@ deepl translate "This is a very long sentence that demonstrates word wrapping." - Table format is only available when translating to multiple target languages. For single language translations, use default plain text or JSON format. - The Characters column is only shown when using `--show-billed-characters` flag. - Without `--show-billed-characters`, the Translation column is wider (70 characters vs 60) for better readability. -- When the API returns metadata (billed characters, model type used), it is appended below the translated text in plain text output and included as fields in JSON output. +- Model type, when the API reports it, is carried in JSON output as `modelTypeUsed`. Billed characters are **not**: a single-target `--format json` run emits `{text, targetLang, detectedSourceLang?, modelTypeUsed?, cached?}` and no character count, whatever `--show-billed-characters` is set to. Multi-target JSON (`--to es,fr,de --format json`) does carry `billedCharacters` per translation. In plain text output both are appended below the translated text. --- @@ -686,7 +729,7 @@ Enhance text quality with AI-powered grammar checking, style improvement, and to **Language:** -- `--lang, -l LANG` - Target language: `de`, `en`, `en-GB`, `en-US`, `es`, `fr`, `it`, `ja`, `ko`, `pt`, `pt-BR`, `pt-PT`, `zh`, `zh-Hans`. Optional — omit to auto-detect the language and rephrase in the original language. +- `--lang, -l LANG` - Target language: `de`, `en`, `en-gb`, `en-us`, `es`, `fr`, `it`, `ja`, `ko`, `pt`, `pt-br`, `pt-pt`, `zh`, `zh-hans`. Optional — omit to auto-detect the language and rephrase in the original language. - `--to LANG` - Long-only alias of `--lang`. Accepts the same language values. Provided for muscle-memory consistency with `deepl translate --to`; the short form `-t` is intentionally **not** bound here (it would collide with `deepl translate -t, --to`). Specifying both `--to` and `--lang` with different values exits with a `ValidationError`. **Style Options (mutually exclusive with tone):** @@ -713,18 +756,26 @@ Enhance text quality with AI-powered grammar checking, style improvement, and to | Target language | `--style` | `--tone` | |-----------------|:---------:|:--------:| -| `en`, `en-GB`, `en-US`, `de` | ✓ | ✓ | -| `es`, `fr`, `it`, `pt`, `pt-BR`, `pt-PT` | ✓ | ✓ | -| `ja`, `ko`, `zh`, `zh-Hans` | — | — | +| `en`, `en-gb`, `en-us`, `de` | ✓ | ✓ | +| `es`, `fr`, `it`, `pt`, `pt-br`, `pt-pt` | ✓ | ✓ | +| `ja`, `ko`, `zh`, `zh-hans` | — | — | When `--style` or `--tone` is set for a target language that does not support it, the server returns a 4xx; the CLI converts that response into a `ValidationError` (exit code 6) that names the unsupported combination and points back to this table. +This table is maintained by hand and reflects what the API **accepts**, which is not always what its metadata reports: `GET /v3/languages?resource=write` omits `writing_style` for `en`, yet `--style` with `--lang en` succeeds and returns what `--lang en-us` returns. Do not narrow this table to the metadata without re-checking behaviour. + +**Where the target-language list comes from:** + +The 14 languages are generated from `GET /v3/languages?resource=write` into `src/data/language-entries.ts` by `npm run generate:languages`, alongside the translation list, and `npm run check:languages` reports drift in either. The `WriteLanguage` type is derived from that same list, so a language added upstream widens it on regenerate rather than needing a second hand edit. + +`write` and `correct` check `--lang` against this list locally and name every valid option, because the set is small enough to enumerate in an error. The list is a snapshot, though, so a code that is *shaped* like a language tag but absent from it is sent to the API with the list as a warning rather than rejected — otherwise a language DeepL adds is unreachable until the snapshot is regenerated. Input that is not shaped like a language tag still exits 6 locally. + **Output Options:** -- `--alternatives, -a` - Show all improvement alternatives +- `--alternatives, -a` - Show all improvement alternatives. With `--format json`, emits them as an array - `--interactive, -i` - Interactive mode: choose from multiple alternatives -- `--diff, -d` - Show diff between original and improved text -- `--check` - Check if text needs improvement without modifying (exits with 0 if no changes, 8 if improvements suggested) +- `--diff, -d` - Show diff between original and improved text. With `--format json`, emits the diff payload with an uncoloured patch +- `--check` - Check if text needs improvement without modifying (exits with 0 if no changes, 8 if improvements suggested). With `--format json`, emits the check result payload on stdout - `--fix` - Auto-fix files in place - `--output, -o FILE` - Write output to file - `--in-place` - Edit file in place @@ -739,18 +790,18 @@ When `--style` or `--tone` is set for a target language that does not support it - `de` - German - `en` - English (generic, defaults to American English) -- `en-GB` - British English -- `en-US` - American English +- `en-gb` - British English +- `en-us` - American English - `es` - Spanish - `fr` - French - `it` - Italian - `ja` - Japanese - `ko` - Korean - `pt` - Portuguese (generic, defaults to Brazilian Portuguese) -- `pt-BR` - Brazilian Portuguese -- `pt-PT` - European Portuguese +- `pt-br` - Brazilian Portuguese +- `pt-pt` - European Portuguese - `zh` - Chinese (generic, defaults to Simplified Chinese) -- `zh-Hans` - Simplified Chinese +- `zh-hans` - Simplified Chinese #### Examples @@ -764,7 +815,7 @@ deepl write "Me and him went to store." **With explicit language:** ```bash -deepl write "Me and him went to store." --lang en-US +deepl write "Me and him went to store." --lang en-us # → "He and I went to the store." ``` @@ -772,11 +823,11 @@ deepl write "Me and him went to store." --lang en-US ```bash # Business style -deepl write "We want to tell you about our product." --lang en-US --style business +deepl write "We want to tell you about our product." --lang en-us --style business # → "We are pleased to inform you about our product." # Casual style -deepl write "The analysis demonstrates significant findings." --lang en-US --style casual +deepl write "The analysis demonstrates significant findings." --lang en-us --style casual # → "The analysis shows some pretty big findings." ``` @@ -784,67 +835,117 @@ deepl write "The analysis demonstrates significant findings." --lang en-US --sty ```bash # Confident tone -deepl write "I think this might work." --lang en-US --tone confident +deepl write "I think this might work." --lang en-us --tone confident # → "This will work." # Diplomatic tone -deepl write "Your approach is wrong." --lang en-US --tone diplomatic +deepl write "Your approach is wrong." --lang en-us --tone diplomatic # → "Perhaps we could consider an alternative approach." ``` **Show alternatives:** ```bash -deepl write "This is good." --lang en-US --alternatives +deepl write "This is good." --lang en-us --alternatives ``` **File operations:** ```bash # Improve file and save to new location -deepl write document.txt --lang en-US --output improved.txt +deepl write document.txt --lang en-us --output improved.txt # Edit file in place -deepl write document.txt --lang en-US --in-place +deepl write document.txt --lang en-us --in-place # Auto-fix with backup -deepl write document.txt --lang en-US --fix --backup +deepl write document.txt --lang en-us --fix --backup ``` **Interactive mode:** ```bash # Choose from multiple alternatives interactively -deepl write "Text to improve." --lang en-US --interactive +deepl write "Text to improve." --lang en-us --interactive ``` **Check mode:** ```bash # Check if file needs improvement (exit code 8 if changes needed) -deepl write document.md --lang en-US --check +deepl write document.md --lang en-us --check ``` +`--check` reports a result rather than an error, so `--format json` gives it a +success shape rather than the error envelope: + +```bash +deepl write document.md --check --format json +# {"ok":true,"mode":"write","needsChanges":true,"changes":3,"file":"/abs/path/document.md"} + +deepl write "This is fine." --check --format json +# {"ok":true,"mode":"write","needsChanges":false,"changes":0} +``` + +| Field | Meaning | +| ------------- | -------------------------------------------------------------------- | +| `ok` | Always `true` — the check ran. A failure emits the error envelope. | +| `mode` | `write` or `correct`, so one parser serves both commands. | +| `needsChanges`| The verdict, repeated by the exit code (`0` clean, `8` needs changes).| +| `changes` | Number of word-level changes the API would make. | +| `file` | Absolute path of the checked file; absent when the input was text. | + +The payload goes to **stdout** and replaces the human report — the `File: …` line +and the `⚠ Text needs improvement` / `✓ Text looks good` verdict stay on stderr in +text mode only. The exit code is unchanged either way, so a CI job may branch on +the code, the payload, or both. + **Diff view:** ```bash # Show differences between original and improved -deepl write file.txt --lang en-US --diff +deepl write file.txt --lang en-us --diff ``` **JSON output:** ```bash # Get machine-readable JSON output -deepl write "This are good." --lang en-US --format json -# {"original":"This are good.","improved":"This is good.","changes":1,"language":"en-US"} +deepl write "This are good." --lang en-us --format json +# {"original":"This are good.","improved":"This is good.","changes":1,"language":"en-us"} ``` +Each output mode has its own JSON shape, because each answers a different +question. `--check` and the two below carry `ok: true`, marking them as results +rather than the `ok: false` error envelope; the plain improvement payload above +predates the envelope and keeps its shape, so it has no `ok` field. + +```bash +# --diff: the same three things the text report shows, with an uncoloured patch +deepl write "This are good." --diff --format json +# {"ok":true,"original":"This are good.","improved":"This is good.", +# "diff":"Index: text\n===...\n-This are good.\n+This is good.\n"} + +# --alternatives: every improvement the API offered, as an array +deepl write "This are good." --alternatives --format json +# {"ok":true,"original":"This are good.","alternatives":["This is good.","These are good."]} +``` + +`--diff`'s `diff` field is the unified patch with no colour escapes, whatever the +terminal — the text report colours the same patch for a human, and that colouring +never reaches the payload. `--diff` ignores `--output`/`--in-place`, so its payload +only ever goes to stdout. + +`--output ` and `--in-place` receive whatever the improvement renders to, so +under `--format json` they write the payload rather than the improved text — that +is true of the plain payload and of `--alternatives` alike. To put improved *text* +in a file, leave `--format` at its default. + **Bypass cache:** ```bash # Force a fresh API call, skipping cached results -deepl write "Improve this text." --lang en-US --no-cache +deepl write "Improve this text." --lang en-us --no-cache ``` --- @@ -872,20 +973,20 @@ Fixes spelling and grammar only, avoiding the broader rewording that `deepl writ **Language:** -- `--lang, -l LANG` - Target language: `de`, `en`, `en-GB`, `en-US`, `es`, `fr`, `it`, `ja`, `ko`, `pt`, `pt-BR`, `pt-PT`, `zh`, `zh-Hans`. Optional — omit to auto-detect the language and correct in the original language. +- `--lang, -l LANG` - Target language: `de`, `en`, `en-gb`, `en-us`, `es`, `fr`, `it`, `ja`, `ko`, `pt`, `pt-br`, `pt-pt`, `zh`, `zh-hans`. Optional — omit to auto-detect the language and correct in the original language. - `--to LANG` - Long-only alias of `--lang`, as on `write`. **Output Modes:** -- `--alternatives, -a` - Show all alternative corrections +- `--alternatives, -a` - Show all alternative corrections. With `--format json`, emits them as an array - `--output, -o FILE` - Write corrected text to file - `--in-place` - Edit file in place (use with file input) - `--interactive, -i` - Review the correction before accepting -- `--diff, -d` - Show diff between original and corrected text +- `--diff, -d` - Show diff between original and corrected text. With `--format json`, emits the diff payload with an uncoloured patch **Fix Operations:** -- `--check` - Check if text needs correction (exit 0 if clean, exit 8 if corrections needed) +- `--check` - Check if text needs correction (exit 0 if clean, exit 8 if corrections needed). With `--format json`, emits the check result payload on stdout - `--fix` - Automatically fix file in place - `--backup, -b` - Create backup file before fixing (use with `--fix`) @@ -912,6 +1013,15 @@ deepl c "Their going too the store." deepl correct README.md --check ``` +With `--format json` the check emits the same result payload `write --check` +does, with `mode` set to `correct` (the field table is under `write`'s **Check +mode** above): + +```bash +deepl correct README.md --check --format json +# {"ok":true,"mode":"correct","needsChanges":true,"changes":2,"file":"/abs/path/README.md"} +``` + **Fix a file in place with a backup:** ```bash @@ -937,6 +1047,10 @@ deepl correct "Their going too the store." --format json # {"original":"Their going too the store.","improved":"They're going to the store.","changes":1,"language":"auto-detected"} ``` +`--check`, `--diff` and `--alternatives` each carry their own payload, identical to +`write`'s (see **JSON output** under `write` above); only `--check`'s differs, in +that its `mode` reads `correct`. + --- ### voice @@ -962,7 +1076,7 @@ deepl voice [options] | `--to ` | `-t` | Target language(s), comma-separated, max 5 (required) | - | | `--from ` | `-f` | Source language (auto-detect if not specified) | auto | | `--formality ` | | Formality level: `default`, `formal`, `more`, `informal`, `less`, `prefer_more`, `prefer_less` | `default` | -| `--glossary ` | | Use glossary by name or ID | - | +| `--glossary ` | | Use glossary by name or ID (single glossary; not repeatable) | - | | `--content-type ` | | Audio content type (auto-detected from file extension) | auto | | `--chunk-size ` | | Audio chunk size in bytes | `6400` | | `--chunk-interval ` | | Interval between audio chunks in milliseconds | `200` | @@ -974,6 +1088,16 @@ deepl voice [options] > **Note:** All formality values (`default`, `formal`, `informal`, `more`, `less`, `prefer_more`, `prefer_less`) are accepted. The voice API natively uses `formal`/`informal` (in addition to `more`/`less`), while the translate API uses `prefer_more`/`prefer_less`. +> **Note:** If the server ends the stream after transcribing the audio but without sending a translation for one of `--to`'s languages, the command fails with exit code 9 and names the languages, rather than printing an empty translation line and exiting 0. Audio containing no speech transcribes to nothing and is translated to nothing, which is not an error and still exits 0. + +> **Note:** the Voice API supports a smaller language set than `translate`, and the CLI checks `--to`/`--from` against it locally — an unlisted code exits 6 before any request, naming every valid option. +> +> **Targets (39):** `ar`, `bg`, `cs`, `da`, `de`, `el`, `en`, `en-gb`, `en-us`, `es`, `et`, `fi`, `fr`, `he`, `hu`, `id`, `it`, `ja`, `ko`, `lt`, `lv`, `nb`, `nl`, `pl`, `pt`, `pt-br`, `pt-pt`, `ro`, `ru`, `sk`, `sl`, `sv`, `th`, `tr`, `uk`, `vi`, `zh`, `zh-hans`, `zh-hant` +> +> **Sources (30):** `ar`, `bg`, `cs`, `da`, `de`, `el`, `en`, `es`, `et`, `fi`, `fr`, `hu`, `id`, `it`, `ja`, `ko`, `lt`, `lv`, `nb`, `nl`, `pl`, `pt`, `ro`, `ru`, `sk`, `sl`, `sv`, `tr`, `uk`, `zh` +> +> Codes are matched case-insensitively, so the lowercase spelling `deepl languages` prints is accepted; the CLI canonicalizes to the casing the Voice API expects before sending. + #### Supported Audio Formats | Extension | Content Type | @@ -1057,9 +1181,12 @@ Monitor files or directories for changes and automatically translate them. Suppo **Behavior:** - Runs continuously until interrupted (Ctrl+C) -- Shows translation statistics on exit +- Shows translation statistics on exit, and exits **12** rather than 0 when the session recorded any failed translation or failed auto-commit - Detects file changes using filesystem watch - Debounces rapid changes to avoid duplicate translations +- Translates one version of a file at a time. An edit arriving while a file's translation is still running queues exactly one re-translation, which starts after the running one has written. Two translations of the same file never overlap, so a slower translation of older content cannot overwrite a newer one, and an edit storm costs two translations rather than one per event +- Skips files inside the output directory whose name carries a target-language segment (`doc.es.md` with `--to es`), so the CLI's own output does not re-trigger the watcher. The check is limited to the output directory, so a *source* file named that way — `pricing.es.md` translated to `es` — is translated normally as long as it lives outside it. When such a file is skipped and the CLI did not write it, the reason is printed once per file +- Mirrors each source file's directory under the output directory, relative to the watched path: watching `docs/` writes `docs/a/index.md` to `/a/index.es.md` and `docs/b/index.md` to `/b/index.es.md`. This is the same layout `deepl translate --output ` produces, and it is what keeps two same-named files in different directories from writing one output path. A file at the top of the watched directory, and a watched path that is a single file, write straight into the output directory as before #### Options @@ -1068,8 +1195,8 @@ Monitor files or directories for changes and automatically translate them. Suppo - `--to, -t LANGS` - Target language(s), comma-separated (uses configured `defaults.targetLangs` if omitted) - `--output, -o DIR` - Output directory (default: `/translations` for directories, same dir for files) - `--pattern GLOB` - File pattern filter (e.g., `*.md`, `**/*.json`) -- `--debounce MS` - Debounce delay in milliseconds (default: 500) -- `--concurrency NUM` - Maximum parallel translations (default: 5) +- `--debounce MS` - Debounce delay in milliseconds. The flag wins, then the configured `watch.debounceMs`, then the default of 500 +- `--concurrency NUM` - Maximum parallel translations across *different* files (default: 5). A single file is always translated one version at a time, whatever this is set to **Translation Options:** @@ -1077,12 +1204,12 @@ Monitor files or directories for changes and automatically translate them. Suppo - `--formality LEVEL` - Formality level: `default`, `more`, `less`, `prefer_more`, `prefer_less`, `formal`, `informal` - `--preserve-code` - Preserve code blocks - `--preserve-formatting` - Preserve line breaks and whitespace formatting -- `--glossary NAME-OR-ID` - Use glossary by name or ID for consistent terminology +- `--glossary NAME-OR-ID` - Use glossary by name or ID for consistent terminology. A source language is required, because the API refuses a glossary without one ("Use of a glossary requires the source_lang parameter to be specified"): pass `--from`, or set `defaults.sourceLang` and it is used automatically. With neither, the command exits 6 before the watcher starts rather than failing on every file change. Unlike `translate`, `watch` takes a single glossary. **Git Integration:** -- `--auto-commit` - Auto-commit translations to git after each change -- `--git-staged` - Only watch git-staged files (snapshot taken once at startup) +- `--auto-commit` - Auto-commit translations to git after each change. One commit per translated source file, queued one at a time: git holds `.git/index.lock` for the duration of an `add` or a `commit`, so parallel translations cannot commit in parallel. A failed commit is reported and counted, and the session exits 12 rather than 0 (see below). The commit goes to the repository holding the **output directory**, whatever directory the CLI was started in — git can only commit files inside its own working tree, so that is the only repository the translations can reach. When the output directory is in no repository at all, the command exits 6 before the watcher starts rather than warning once per translated file +- `--git-staged` - Only watch git-staged files (snapshot taken once at startup). The index read is the one belonging to the **watched path**'s repository, so the CLI need not be started from its root - `--dry-run` - Show what would be watched without starting the watcher #### Examples @@ -1134,7 +1261,7 @@ git add docs/guide.md docs/faq.md deepl watch docs/ --to de,ja --git-staged --auto-commit ``` -> **Note:** `--git-staged` takes a one-time snapshot of staged files at startup. Files staged after the watcher starts are not included. Requires a git repository — exits with an error otherwise. +> **Note:** `--git-staged` takes a one-time snapshot of staged files at startup. Files staged after the watcher starts are not included. The watched path must be inside a git repository — the command exits with an error otherwise — and it is that repository's index that is read, not the one belonging to the directory the CLI was started in. --- @@ -1185,8 +1312,10 @@ Scan, translate, and sync i18n resource files. The sync engine reads `.deepl-syn - `--watch --force` is rejected at startup with a `ValidationError` (exit 6) to prevent unbounded billing from a forced re-translation on every file save. - In an interactive terminal, `--force` prompts for confirmation before bypassing the cost cap. Pass `--yes` (`-y`) to skip the prompt in scripts. - In CI environments (`CI=true`), `--force` requires an explicit `--yes`; otherwise the process exits 6 with an actionable hint naming the missing flag. + - Anywhere else the prompt cannot be shown — a git hook, cron job, `make` target, container entrypoint, `deepl sync --force < /dev/null`, or `--no-input` — `--force` is **refused** with exit 6 rather than assumed to be confirmed. `--yes` is the only way to run it unattended. `--force` overwrites every target file, including translations edited by hand, and no `.deepl.bak` survives a successful run, so there is nothing to recover from afterwards. -- `--yes, -y` - Skip the `--force` confirmation prompt (required when `CI=true`) +- `--yes, -y` - Skip the `--force` confirmation prompt (required when `CI=true`, and whenever there is no terminal to prompt on) +- `--break-lock` - Take the sync lock even when `.deepl-sync.lock.pidfile` names a process that looks alive, reporting the holder it removed. Also accepted by `sync pull` and `sync resolve`, which take the same lock. Use it only when that run is definitely not running: two concurrent syncs write the same target files and the same lockfile. Not carried into `--watch` cycles — it applies to the run you asked for, then the lock arbitrates normally again. See [Concurrent sync](SYNC.md#concurrent-sync) **Filtering:** @@ -1242,6 +1371,7 @@ Interactive setup wizard that creates `.deepl-sync.yaml` by scanning the project - `--target-locales CODES` - Target locales (comma-separated) - `--file-format TYPE` - File format: `json`, `yaml`, `toml`, `po`, `android_xml`, `ios_strings`, `xcstrings`, `arb`, `xliff`, `properties`, `laravel_php` - `--path GLOB` - Source file path or glob pattern +- `--format FORMAT` - Output format: `text` (default), `json`. Under `json`, success emits the envelope described below and failure emits the shared error envelope, both on stdout - `--sync-config PATH` - Path to `.deepl-sync.yaml` `--source-lang` and `--target-langs` were accepted as deprecated aliases during `1.x` and were removed in `2.0.0`; use `--source-locale` / `--target-locales`. `deepl translate --target-lang` is unchanged — it operates on strings and stays aligned with the DeepL API's wire name. @@ -1274,16 +1404,27 @@ Show translation coverage for all target locales. "totalKeys": 142, "skippedKeys": 1, "locales": [ - { "locale": "de", "complete": 140, "missing": 2, "outdated": 0, "coverage": 98 } - ] + { + "locale": "de", + "complete": 140, + "missing": 2, + "outdated": 0, + "unwritten": 0, + "needsReview": 0, + "coverage": 98 + } + ], + "unwrittenByLocale": [] } ``` +`unwritten`, `needsReview` and `unwrittenByLocale` are always present, even when zero or empty — see the two paragraphs at the end of this subcommand for what each counts. + `skippedKeys` counts entries the parser tagged as untranslatable and excluded from the translation batch — currently only Laravel pipe-pluralization values (`|{n}`, `|[n,m]`, `|[n,*]`). Included in `totalKeys`. -**stdout/stderr split (stable contract):** The success JSON payload is written to **stdout**, so `deepl sync status --format json > status.json` produces a parseable file. Diagnostic/progress logs stay on **stderr**. The same stdout/stderr split applies to `deepl sync --format json`, `deepl sync validate --format json`, and `deepl sync audit --format json`. +**stdout/stderr split (stable contract):** The JSON payload — the success result, or the error envelope below when the command fails — is written to **stdout**, so `deepl sync status --format json > status.json` produces a parseable file in both cases. Diagnostic/progress logs and warnings stay on **stderr**, which means stdout parses even when the CLI or the Node runtime had something to say. The same stdout/stderr split applies to `deepl sync --format json`, `deepl sync validate --format json`, and `deepl sync audit --format json`. -**Error envelope (shared across every `sync` subcommand):** On failure, `--format json` emits the following JSON envelope to **stderr** and exits with the typed exit code: +**Error envelope (shared across every command with a JSON mode):** On failure, `--format json` emits the following JSON envelope to **stdout** — the envelope is the command's result in the failure case; the non-zero exit code is the failure signal — and exits with the typed exit code: ```json { @@ -1297,7 +1438,14 @@ Show translation coverage for all target locales. } ``` -The `error.code` field matches the error class name (`ConfigError`, `ValidationError`, `SyncConflict`, `AuthError`, etc.). `error.suggestion` is present when the underlying `DeepLCLIError` carries one. `exitCode` matches the process exit code, so a caller can branch on either field. The envelope shape is identical for `deepl sync`, `sync push`, `sync pull`, `sync resolve`, `sync export`, `sync validate`, `sync audit`, `sync init`, and `sync status`. +The `error.code` field matches the error class name (`ConfigError`, `ValidationError`, `SyncConflict`, `AuthError`, etc.). `error.suggestion` is present when the underlying `DeepLCLIError` carries one. `exitCode` matches the process exit code, so a caller can branch on either field. + +**Which commands emit it:** every command whose effective `--format` is `json` — the nine `sync` subcommands plus `translate`, `write`, `correct`, `voice`, `usage`, `languages`, `detect`, and those subcommands of `glossary`, `tm`, `cache`, `config`, `hooks`, `admin` and `style-rules` that declare the flag. The shape and the stream are identical everywhere, so a script wrapping several `deepl` commands needs one failure path. Notes on the edges: + +- A command with no `--format` flag, and any run in `text` or `table` mode, is unchanged: `Error:` / `Suggestion:` prose on stderr. +- `config get`/`config list` default to `json`, so their failures carry the envelope with no flag passed. +- A result that is not an error keeps its own shape: `write --check` / `correct --check` exit `8` to report that text needs changes, and emit their own `ok: true` result payload rather than an envelope; `--diff` and `--alternatives` carry `ok: true` payloads of their own too. Discriminate on `ok`: `false` is a failure, `true` is a result whose detail is in the payload. The one exception is `write`/`correct`'s plain improvement payload (`{original, improved, changes, language}`), which predates the envelope and carries no `ok` field. +- A malformed invocation commander rejects before the command runs (`unknown option`, a missing argument) still prints commander's own message on stderr and exits `6`. The envelope covers command failures, not parse failures. **`sync init --format json` success envelope:** For scripted project bootstrap, `deepl sync init --format json` emits a success envelope on **stdout** instead of the plain text confirmation: @@ -1313,7 +1461,9 @@ The `error.code` field matches the error class name (`ConfigError`, `ValidationE } ``` -**Casing convention:** CLI JSON output uses `camelCase`; the on-disk `.deepl-sync.lock` and `.deepl-sync.yaml` use `snake_case`. The two are deliberately kept separate — JSON output is a consumer contract; the files are authored configuration. +`keys` is present only when the wizard counted the source strings while scanning; a fully non-interactive `sync init` omits it. Treat it as optional. + +**Casing convention:** every `sync` JSON payload uses `camelCase`, while the on-disk `.deepl-sync.lock` and `.deepl-sync.yaml` use `snake_case`. The two are deliberately kept separate — JSON output is a consumer contract; the files are authored configuration. Outside `sync`, commands that pass a DeepL API response straight through keep the API's own `snake_case` (`glossary`, `tm`), as does `detect` (`detected_language`, `language_name`). The two are deliberately kept separate — JSON output is a consumer contract; the files are authored configuration. **Examples:** @@ -1335,6 +1485,10 @@ Source: en (142 keys) ja [##################..] 91% (12 missing, 0 outdated) ``` +A locale also reports **`needsReview`** keys — a translation is present but its format marks it as not ready to ship, which means a gettext `#, fuzzy` msgstr or an XLIFF review `state` such as `needs-review-translation` (1.2) or `initial` (2.0). `msgfmt` leaves a fuzzy entry out of the compiled catalog, so the application shows the source string; counting such a key complete would report a project as done while it ships untranslated text. An XLIFF unit with no `state` attribute, or one carrying a value outside the recognised sets, counts as complete — see docs/SYNC.md for the two tables. Such a key is never re-translated and the file is never rewritten for it — the value is a reviewer's draft — so the count is a report, not a repair, and nothing is billed. The suffix `, N needs review` and its explanatory line appear only when the count is non-zero, and `--format json` always carries `needsReview` on each locale. `deepl sync push` skips such a key under a `needs_review` skip reason rather than uploading it as an approved translation. `sync --frozen` deliberately does **not** treat it as drift: such a marker is a normal, transient state in a review workflow that only a human clears, and nothing the CLI did caused it. + +A locale also reports **`unwritten`** keys — recorded as translated in `.deepl-sync.lock` but not in that locale's target file — when there are any, followed by a line naming the file and the keys. The suffix is omitted when the count is zero, so the line above is unchanged for a healthy project. `--format json` always carries `unwritten` on each locale plus a top-level `unwrittenByLocale` array of `{locale, file, keys}`, each entry gaining an `unusable` field when the target file could not be read or parsed at all. See [docs/SYNC.md](./SYNC.md#a-string-added-after-the-first-sync) for what is and is not counted. + ##### `validate` Check translations for placeholder integrity and format consistency. @@ -1357,18 +1511,48 @@ deepl sync validate --locale de **Sample output:** ``` -Validation Results: +$ deepl sync validate +Checked 4 translations - de: - ✓ 138/140 strings valid - ✗ 2 issues found: - - messages.welcome: placeholder {name} missing in translation - - errors.count: format specifier %d replaced with %s + ERROR de/greeting: Missing placeholders in translation: {name} + WARN de/bye: Translation is identical to source text - fr: - ✓ 142/142 strings valid +1 error(s), 1 warning(s) +``` + +One line per issue, `ERROR` or `WARN` followed by `/` and the check's message. A run with no issues prints the header and `All translations passed validation.` + +**JSON output contract (stable within a major version):** + +```json +{ + "totalChecked": 3, + "passed": 2, + "warnings": 0, + "errors": 1, + "issues": [ + { + "locale": "de", + "file": "locales/de.json", + "key": "errors.count", + "source": "Hi %s", + "translation": "Hi", + "severity": "error", + "issues": [ + { + "check": "placeholders", + "severity": "error", + "message": "Missing placeholders in translation: %s", + "details": { "expected": ["%s"], "actual": [] } + } + ] + } + ] +} ``` +`totalChecked` counts source/target pairs that were validated; a key absent from the target file is not a pair and is not counted. `passed` is `totalChecked` minus the number of pairs carrying any issue, so a pair with only warnings is not "passed". `errors` is what drives the exit code — see [8 — CheckFailed](#8--checkfailed) for which `check` values are error severity and which are warn-only. A file-level `unusable_target` issue counts toward `errors` but not toward `totalChecked`, and its `key`/`file` are both the target path rather than a translation key. + ##### `audit` Analyze translation consistency and detect terminology inconsistencies across target locales. "Audit" here means translation-consistency audit (detecting term divergence across locales), not security audit in the `npm audit` sense. @@ -1392,10 +1576,13 @@ Analyze translation consistency and detect terminology inconsistencies across ta "translations": ["Armaturenbrett", "Dashboard"], "files": ["locales/en/common.json", "locales/en/admin.json"] } - ] + ], + "missingTargets": [] } ``` +`missingTargets` lists target files that could not be read or parsed and were therefore excluded from the comparison. A non-empty array means the audit's verdict covers fewer locales than the project has; the text output prints the same list under `N target(s) could not be read and were excluded from the comparison:`. + The `translations` array contains the actual translated strings read from target files. If a target file is missing, the content hash falls back in its place. ##### `export` @@ -1407,7 +1594,7 @@ Export source strings to XLIFF 1.2 for CAT tool handoff. - `--locale LANGS` - Filter by locale (comma-separated) - `--output PATH` - Write to file instead of stdout. Path must stay within the project root; intermediate directories are created automatically - `--overwrite` - Required to overwrite an existing `--output` file. Without it, an existing file causes a non-zero exit and no write occurs -- `--format FORMAT` - Output format: `text` (default), `json`. Success output is always XLIFF 1.2 regardless of format; `json` affects the **error** envelope on stderr (matching other sync subcommands) so script consumers can parse failure shape uniformly +- `--format FORMAT` - Output format: `text` (default), `json`. Success output is always XLIFF 1.2 regardless of format; `json` affects the **error** envelope on stdout (matching other sync subcommands) so script consumers can parse failure shape uniformly - `--sync-config PATH` - Path to `.deepl-sync.yaml` **Examples:** @@ -1435,6 +1622,7 @@ Resolve git merge conflicts in `.deepl-sync.lock`. - `--format FORMAT` - Output format: `text` (default), `json` - `--dry-run` - Preview conflict decisions without writing the lockfile - `--sync-config PATH` - Path to `.deepl-sync.yaml` +- `--break-lock` - Take the sync lock even when `.deepl-sync.lock.pidfile` names a process that looks alive **JSON success envelope (stable within a major version):** `{ "ok": true, "resolved": , "decisions": [...] }` @@ -1450,7 +1638,19 @@ Push local translations to a TMS for human review. **Requires TMS integration.** Add a `tms:` block to `.deepl-sync.yaml` (at minimum `enabled: true`, `server`, `project_id`) and supply credentials via the `TMS_API_KEY` or `TMS_TOKEN` environment variable. Running `push` without a configured `tms:` block exits 7 (ConfigError). See [docs/SYNC.md#tms-rest-contract](./SYNC.md#tms-rest-contract) for the full field reference and REST contract. -**JSON success envelope (stable within a major version):** `{ "ok": true, "pushed": , "skipped": [...] }` +**TMS destination trust.** `tms.server` is chosen by `.deepl-sync.yaml`, which lives in the checkout, while `TMS_API_KEY` / `TMS_TOKEN` come from your environment. Before an **environment-supplied** credential is attached to a request, the destination hostname must be one you have approved: + +- Approved if the hostname appears in the user-level `tms.allowedServers` list (`deepl config set tms.allowedServers tms.example.com`, comma-separate several). +- Otherwise, in an interactive terminal, the CLI names the host and what would be sent and asks once. Answering yes records the hostname in **user** config (`~/.config/deepl-cli/config.json`), never in the repository. +- Otherwise — under `--no-input`, or on a non-TTY such as CI — the run fails closed with exit 7 (ConfigError) naming the host and the exact `deepl config set tms.allowedServers ...` command. No credential and no translated string is sent. + +The gate applies only to environment-supplied credentials. A credential inlined as `tms.api_key` / `tms.token` in `.deepl-sync.yaml` is not gated: it belongs to the same file that chose the destination, so nothing of yours leaks. Loopback hosts (`localhost`, `127.0.0.1`) are **not** exempt — a co-tenant process listening locally is still an exfiltration sink. + +Both commands print the resolved destination origin on success, in text and JSON output, so a redirected destination is visible in logs even when the host was already approved. + +**JSON success envelope (stable within a major version):** `{ "ok": true, "pushed": , "skipped": [...], "server": "" }` + +Each `skipped` entry carries a `reason`. `push` emits `untranslated` (the target file lists the key but holds no translation for it — pushing it would upload source text as the locale's approved translation), `needs_review` (the translation is marked as needing review, a gettext `#, fuzzy` msgstr or an XLIFF review `state`, so it is a reviewer's draft rather than an approved translation), `pipe_pluralization` (Laravel pipe-pluralization, never sent to a TMS), and `target_missing` (the target file does not exist yet, common on a first push). `push` never emits `pull`'s reasons and `pull` never emits these. ##### `pull` @@ -1461,10 +1661,26 @@ Pull approved translations from a TMS back into local files. - `--locale LANGS` - Pull specific locales only - `--format FORMAT` - Output format: `text` (default), `json` - `--sync-config PATH` - Path to `.deepl-sync.yaml` +- `--dry-run` - Preview what the pull would change without writing any file +- `--break-lock` - Take the sync lock even when `.deepl-sync.lock.pidfile` names a process that looks alive **Requires TMS integration.** Add a `tms:` block to `.deepl-sync.yaml` (at minimum `enabled: true`, `server`, `project_id`) and supply credentials via the `TMS_API_KEY` or `TMS_TOKEN` environment variable. Running `pull` without a configured `tms:` block exits 7 (ConfigError). See [docs/SYNC.md#tms-rest-contract](./SYNC.md#tms-rest-contract) for the full field reference and REST contract. -**JSON success envelope (stable within a major version):** `{ "ok": true, "pulled": , "skipped": [...] }` +**TMS destination trust.** `tms.server` is chosen by `.deepl-sync.yaml`, which lives in the checkout, while `TMS_API_KEY` / `TMS_TOKEN` come from your environment. Before an **environment-supplied** credential is attached to a request, the destination hostname must be one you have approved: + +- Approved if the hostname appears in the user-level `tms.allowedServers` list (`deepl config set tms.allowedServers tms.example.com`, comma-separate several). +- Otherwise, in an interactive terminal, the CLI names the host and what would be sent and asks once. Answering yes records the hostname in **user** config (`~/.config/deepl-cli/config.json`), never in the repository. +- Otherwise — under `--no-input`, or on a non-TTY such as CI — the run fails closed with exit 7 (ConfigError) naming the host and the exact `deepl config set tms.allowedServers ...` command. No credential and no translated string is sent. + +The gate applies only to environment-supplied credentials. A credential inlined as `tms.api_key` / `tms.token` in `.deepl-sync.yaml` is not gated: it belongs to the same file that chose the destination, so nothing of yours leaks. Loopback hosts (`localhost`, `127.0.0.1`) are **not** exempt — a co-tenant process listening locally is still an exfiltration sink. + +Both commands print the resolved destination origin on success, in text and JSON output, so a redirected destination is visible in logs even when the host was already approved. + +**JSON success envelope (stable within a major version):** `{ "ok": true, "pulled": , "replaced": , "skipped": [...], "server": "", "dryRun": }` + +`replaced` counts keys whose existing local translation the pull overwrote with the TMS version — check it before trusting a pull that ran over hand-edited files. `dryRun` is `true` when `--dry-run` was passed, in which case nothing was written and `pulled`/`replaced` are what a real run would do. + +Each `skipped` entry carries a `reason`. `pull` emits `unusable_target` (the target file could not be read), `key_collision` (the target file's keys collide), `shared_target` (a target file another sync configuration's `.deepl-sync.lock` accounts for keys in, left untouched rather than rebuilt from this configuration's keys alone), `plural_entry` (one exported string cannot fill a gettext `msgstr[N]` or Android `` entry's forms, so the entry is carried forward as it stands), and `no_matches` (no matching keys). See [docs/SYNC.md#two-configurations-writing-one-file](./SYNC.md#two-configurations-writing-one-file). #### Examples @@ -1528,7 +1744,9 @@ No other fields appear in the output. Fields not listed above are internal and m #### Notes -- The `--frozen` flag makes no API calls. It compares the lockfile against source files and exits with code 10 if any translations are missing or outdated. This is the recommended mode for CI/CD pipelines. +- The `--frozen` flag makes no API calls. It compares the lockfile against source files, and each target file against what the lockfile claims about it, and exits with code 10 if any translations are missing, outdated, or recorded as translated while absent from the target file. This is the recommended mode for CI/CD pipelines. +- The `--dry-run` flag makes no API calls and writes nothing — no target file, no lockfile, no backup. It still requires an API key, though: the client is constructed before the run decides it has nothing to send, so `deepl sync --dry-run` without a key exits 2. It reads each target file the lockfile claims translations for, so its estimate covers the same work the real run does: keys the lockfile calls translated that the target file no longer holds are **included** (the run re-translates and re-bills them, and `unwrittenKeys` reports the count), and a locale whose target file is on disk and unreadable is **excluded** and named in a warning, because the run refuses that locale, bills nothing for it and exits 12. Reading the target files is what makes the estimate faithful and is the flag's main cost: measured on a 20,000-key, 6-locale project (2.83 MiB source, 17.3 MiB of target files, 24 MiB lockfile), `--dry-run` went from 0.44 s to 0.75 s, alongside 0.77 s for `sync status` and 0.79 s for `sync --frozen`. The read is skipped entirely for a locale the lockfile claims nothing for, so a project mid-first-sync pays nothing. +- The `sync.max_characters` cost cap quotes from the same estimate as `--dry-run`, so a run the preview prices above the cap is a run the cap refuses. That costs one pass over the target files before the cap decides: on the fixture above, a translating run took 1.54 s with no cap configured and 1.91 s with one. The cap is unaffected for projects that do not set it. - The lockfile (`.deepl-sync.lock`) should be committed to version control. It enables incremental sync by tracking content hashes. - The `push` and `pull` subcommands require a TMS that implements the REST contract documented in [docs/SYNC.md](./SYNC.md#tms-rest-contract). All other commands work with the standard DeepL Translation API. - By default, keys with extracted context are grouped by i18n section and translated in section batches. Use `--no-batch` to force individual per-key context translation. Use `--batch` to force all keys into plain batch calls (no context). @@ -1560,6 +1778,12 @@ Install a git hook. - `hook-type` - Hook type: `pre-commit`, `pre-push`, `commit-msg`, `post-commit` +**Options:** + +- `-y, --yes` - Skip the confirmation prompt when this repository sends hooks outside the working tree + +**Hooks directory outside the repository:** the hook is written wherever git reads hooks from, which a repository-local `core.hooksPath` can point anywhere — including an absolute path outside the checkout. Because that setting travels with the repository rather than coming from you, an install that would land outside the working tree names the configured value and the resolved directory and asks first. Declining, or running without a terminal, exits `6` and writes nothing; `--yes` installs there and prints the same notice to stderr. A `core.hooksPath` that stays inside the working tree (`.husky/_`, for example) is not affected, and neither are linked worktrees or submodules, where git legitimately reads hooks from the repository that owns them. + **Examples:** ```bash @@ -1567,6 +1791,9 @@ deepl hooks install pre-commit deepl hooks install pre-push deepl hooks install commit-msg deepl hooks install post-commit + +# Accept a hooks directory outside the working tree without prompting +deepl hooks install pre-commit --yes ``` ##### `uninstall ` @@ -1584,7 +1811,22 @@ deepl hooks uninstall post-commit ##### `list` -List all hooks and their installation status. +List all hooks and what the file at each hook path is. + +Each hook installed by this CLI carries a marker line recording the SHA-256 of +its body, and `list` checks it. Four states are reported: + +| State | Meaning | +| --------------- | -------------------------------------------------------------------- | +| `installed` | A versioned marker is present and the body hashes to the value it records | +| `modified` | A marker is present and the body does not hash to it — an edit made after installation, or a forged marker | +| `unverified` | A legacy (pre-1.0) marker with no recorded hash to check against | +| `not-installed` | No hook file, or a file carrying no DeepL marker | + +The hash is unkeyed, so `installed` establishes that a hook has not changed +since its marker was written — not that this CLI wrote it. Anyone who can write +the hook can write a marker that agrees with their own content. Treat the marker +as detection of changes to a hook, not as proof of its origin. **Options:** @@ -1597,9 +1839,13 @@ deepl hooks list # JSON output for CI/CD scripting deepl hooks list --format json -# { "pre-commit": true, "pre-push": false, "commit-msg": false, "post-commit": false } +# { "pre-commit": "installed", "pre-push": "modified", "commit-msg": "unverified", "post-commit": "not-installed" } ``` +The JSON values are these state strings, not booleans. Gate on +`state === "installed"`; a truthiness test passes for every state, including +`"not-installed"`. + ##### `path ` Show the path to a hook file. @@ -1658,8 +1904,8 @@ cache caché # Create single-target glossary from TSV file deepl glossary create tech-terms en es glossary.tsv # ✓ Glossary created: tech-terms (ID: abc123...) -# Source language: EN -# Target languages: ES +# Source language: en +# Target languages: es # Type: Single target # Total entries: 3 @@ -1719,8 +1965,8 @@ Show glossary details including name, ID, languages, creation date, and entry co deepl glossary show tech-terms # Name: tech-terms # ID: abc123... -# Source language: EN -# Target languages: DE +# Source language: en +# Target languages: de # Type: Single target # Total entries: 3 # Created: 2024-10-07T12:34:56.000Z @@ -1729,15 +1975,15 @@ deepl glossary show tech-terms deepl glossary show multilingual-terms # Name: multilingual-terms # ID: def456... -# Source language: EN -# Target languages: ES, FR, DE +# Source language: en +# Target languages: es, fr, de # Type: Multilingual # Total entries: 15 # # Language pairs: -# EN → ES: 5 entries -# EN → FR: 5 entries -# EN → DE: 5 entries +# en → es: 5 entries +# en → fr: 5 entries +# en → de: 5 entries # Created: 2024-10-08T10:00:00.000Z ``` @@ -2077,7 +2323,7 @@ List all translation memories on the account. **Output Format (text):** -- Per-TM: `name (source → target[, target...])` — e.g. `brand-terms (EN → DE, FR, JA)`. Control chars and zero-width codepoints are stripped from the rendered name to prevent a malicious API-returned name from corrupting the terminal via ANSI escape sequences. +- Per-TM: `name (source → target[, target...])` — e.g. `brand-terms (en → de, fr, ja)`. Control chars and zero-width codepoints are stripped from the rendered name to prevent a malicious API-returned name from corrupting the terminal via ANSI escape sequences. - Empty list: `No translation memories found` **Output Format (JSON):** @@ -2088,8 +2334,8 @@ Raw `TranslationMemory[]` as returned by `GET /v3/translation_memories` — fiel ```bash deepl tm list -# brand-terms (EN → DE, FR, JA) -# legal-phrases (EN → FR) +# brand-terms (en → de, fr, ja) +# legal-phrases (en → fr) deepl tm list --format json | jq '.[] | select(.name == "brand-terms") | .translation_memory_id' # "3f2504e0-4f89-41d3-9a0c-0305e82c3301" @@ -2250,7 +2496,7 @@ Show API usage statistics. #### Synopsis ```bash -deepl usage +deepl usage [OPTIONS] ``` #### Description @@ -2282,14 +2528,10 @@ deepl usage # API Key Usage: # Used: 1,880,000 / unlimited # -# Speech-to-Text Usage: -# Used: 12m 34s / 1h 0m 0s (20.9%) -# Remaining: 47m 26s -# # Product Breakdown: # translate: 900,000 characters (API key: 880,000) # write: 1,250,000 characters (API key: 1,000,000) -# speech_to_text: 12m 34s (API key: 12m 34s) +# speech_to_text: 12m 34s (API key) ``` **Output Fields:** @@ -2303,8 +2545,7 @@ deepl usage - **Billing Period**: Start and end dates of the current billing cycle - **API Key Usage**: Characters used by this specific API key (vs. the whole account) -- **Speech-to-Text Usage**: Duration used and remaining for speech-to-text quota (displayed as hours/minutes/seconds) -- **Product Breakdown**: Per-product character counts (translate, write) and durations (speech_to_text) with API key-level breakdown +- **Product Breakdown**: Per-product character counts (translate, write) and durations (speech_to_text) with the API-key-level figure alongside. A duration-billed product shows `(API key)` when the response carries no account-wide total, rather than repeating the key's own figure as if it were one. **Notes:** @@ -2327,7 +2568,7 @@ deepl languages [OPTIONS] #### Description -Display all 121 supported languages grouped by category. Core and regional languages are shown first, followed by extended languages. When an API key is configured, language names are fetched from the DeepL API; otherwise, the local language registry is used. +Display all 125 supported languages grouped by category. Core and regional languages are shown first, followed by extended languages. When an API key is configured, language names are fetched from the DeepL API; otherwise, the local language registry is used. You can filter to show only source languages, only target languages, or both (default). @@ -2335,6 +2576,7 @@ You can filter to show only source languages, only target languages, or both (de - `--source, -s` - Show only source languages - `--target` - Show only target languages +- `--features` - Show which features each language supports (requires an API key; the local registry carries no feature data) - `--format FORMAT` - Output format: `text`, `json`, `table` (default: `text`). In non-TTY output, `table` falls back to `text` with a `WARN` line on stderr. #### Examples @@ -2372,6 +2614,23 @@ deepl languages --source # Show only target languages deepl languages --target +# Show which features each language supports +deepl languages --target --features +# Target Languages: +# de German — formality, glossary, style rules, translation memory, auto detection +# pt Portuguese — formality, glossary, auto detection +# en-gb English (British) — glossary, style rules, translation memory +# ... +# Extended Languages (quality_optimized only, no formality/glossary): +# hi Hindi — auto detection +# th Thai — style rules, translation memory, auto detection +# ... +# +# All listed languages also support: tag handling. + +# The same matrix as columns +deepl languages --target --features --format table + # Works without API key (shows local registry data) deepl languages # Note: No API key configured. Showing local language registry only. @@ -2384,11 +2643,29 @@ deepl languages - Target languages that support the `--formality` parameter are marked with `[F]` (requires API key) - Language codes are left-aligned and padded for readability +**Feature matrix (`--features`):** + +- Feature support comes from `GET /v3/languages`; a feature is supported when the API reports it for that language +- Which features are shown is derived from the response, not a fixed list. A feature only appears when its support differs across the languages listed; one supported by all of them is reported once as `All listed languages also support: ...` instead of being repeated on every row +- Because of that, the columns differ between listings: `auto detection` appears under `--target` (target-only variants lack it) but is uniform under `--source` +- A feature that is not yet generally available shows its status instead of `yes`, e.g. `glossary (beta)` +- `--features` replaces the `[F]` shorthand, since formality is one of the reported features +- `--format json` includes a raw `features` object with each feature's status, but only when `--features` is passed + +**Where the language list comes from:** + +- `GET /v3/languages` is the authority on which languages exist. The CLI bundles a snapshot of it so that listing and validating languages works offline and without an API key +- The snapshot is generated, not hand-maintained (`npm run generate:languages`; `npm run check:languages` reports drift). Tiers are derived from the response — glossary support separates extended from the rest, source usability separates core from regional — so they are not a separate judgement that can disagree with the API +- Because the snapshot can lag the API, a **well-formed language code it does not list is accepted and sent to the API**, which accepts or rejects it authoritatively. Input that is not shaped like a language tag is still rejected locally, with a pointer to `deepl languages`. This applies to `translate`, `sync` and to language values in the config file +- The listing itself is the union of the API response and the snapshot, so a language DeepL offers is never hidden even if the snapshot predates it + **Notes:** -- Source and target language lists differ: 7 regional variants (en-gb, en-us, es-419, pt-br, pt-pt, zh-hans, zh-hant) are target-only +- Source and target language lists differ: 11 regional variants (de-ch, de-de, en-gb, en-us, es-419, fr-ca, fr-fr, pt-br, pt-pt, zh-hans, zh-hant) are target-only - Extended languages (82 codes) only support `quality_optimized` model type and do not support formality or glossary features -- Without an API key, the command shows all languages from the local registry with a warning +- The API reports the same display name for a bare code and its explicit-region variant — both `de` and `de-de` are "German", both `fr` and `fr-fr` are "French". The CLI mirrors the API rather than inventing distinct names; the code column distinguishes them +- The extended tier is a coarser signal than the feature matrix: some extended languages do support style rules and translation memory even though they support neither formality nor glossary +- Without an API key, the command shows all languages from the local registry with a warning; `--features` additionally warns that it needs a key --- @@ -2440,7 +2717,7 @@ echo "$LANG" # es - Requires an API key (the detection uses a translate API call) - Each detection call consumes character quota (the text is translated to produce the detection) - Very short text (single characters or words) may produce unreliable detection results -- Supports all 121 languages recognized by the DeepL API (core, regional, and extended) +- Supports all 125 languages recognized by the DeepL API (core, regional, and extended) --- @@ -3036,6 +3313,9 @@ Existing `~/.deepl-cli/` installations continue to work with no changes needed. "debounceMs": 500, "autoCommit": false, "pattern": "*.md" + }, + "tms": { + "allowedServers": [] } } ``` @@ -3043,10 +3323,28 @@ Existing `~/.deepl-cli/` installations continue to work with no changes needed. **Configuration Notes:** - **`baseUrl`** — when set to a custom/regional endpoint (e.g. `https://api-jp.deepl.com`), it overrides all auto-detection. Standard DeepL URLs (`api.deepl.com`, `api-free.deepl.com`) are treated as tier defaults and do **not** override key-based auto-detection. By default, the endpoint is auto-detected from the API key: keys ending with `:fx` use the Free API (`api-free.deepl.com`), all others use the Pro API (`api.deepl.com`). The `usePro` flag serves as a backward-compatible fallback for non-`:fx` keys. +- **`tms.allowedServers`** — hostnames approved as TMS destinations for an environment-supplied `TMS_API_KEY` / `TMS_TOKEN`. Empty by default, so no destination is trusted implicitly. Entries must be bare hostnames (no scheme, port, path, or wildcard) because they are matched against a parsed URL hostname, exactly and case-insensitively — a listed `example.com` does not approve `tms.example.com`. Set it with `deepl config set tms.allowedServers a.example.com,b.example.com`; a single host is still stored as a one-element array. See [`sync push`](#push) for how the gate behaves. - Most users configure settings via `deepl config set` command rather than editing the file directly. --- +## Terminal Output Safety + +Translations, i18n keys, glossary entries and API error messages can all contain bytes the CLI did not author. Terminal control sequences hidden in that text can set the window title, write the clipboard (OSC 52), erase the screen, or forge a plausible-looking result line. The CLI neutralizes them, replacing each with `?`: + +| Stream | When | Behavior | +| ------------------------------------------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------- | +| **stderr** (progress, warnings, errors) | Always | Control sequences replaced. Non-TTY stderr is still rendered by CI log viewers that interpret ANSI. | +| **stdout**, when it is a terminal | Always | Control sequences replaced. | +| **stdout**, when redirected to a file, a pipe or command substitution | Never | Byte-for-byte identical to what the API returned, so `deepl translate ... > out.txt` is lossless. | +| **Report lines** that interpolate untrusted values (for example the key in `deepl sync validate`) | Always | Control sequences replaced whether or not stdout is a terminal, since these are diagnostics, not data. | + +Colour (SGR) sequences are preserved so `deepl` output stays readable; use [`NO_COLOR`](#no_color) to turn colour off. Tabs, newlines, carriage returns and Unicode formatting characters that are legitimate translation content — zero-width joiners, and the bidi marks used in Arabic, Hebrew and Persian text — are never altered on stdout. + +`deepl sync export` writes XLIFF rather than terminal output. There, tab, newline and carriage return are emitted as the character references ` `, ` ` and ` ` so a key survives XML attribute-value normalization unchanged, and the remaining C0 control characters — which XML 1.0 cannot represent at all — are replaced with `?`. + +--- + ## Environment Variables ### `DEEPL_API_KEY` @@ -3122,6 +3420,14 @@ Route outbound DeepL API requests through an HTTPS proxy. Takes precedence over export HTTPS_PROXY="http://proxy.example.com:3128" ``` +### `NO_PROXY` + +Comma-separated list of hosts that bypass `HTTP_PROXY` / `HTTPS_PROXY`. Standard semantics apply: `*` bypasses everything, a leading dot or `*.` matches subdomains, and an entry may carry a `host:port` that must agree with the target port. Also recognized as lowercase `no_proxy`. + +```bash +export NO_PROXY="localhost,127.0.0.1,.internal.example.com" +``` + ### `TMS_API_KEY` API key used by `deepl sync push` and `deepl sync pull` to authenticate against the external translation management system configured under `tms.server` in `.deepl-sync.yaml`. See [docs/SYNC.md](SYNC.md) for setup details. @@ -3161,14 +3467,14 @@ Retryable codes are `3` (rate limit) and `5` (network); everything else should b | 2 | AuthError | Authentication failed or API key missing | No | | 3 | RateLimitError | Rate limit exceeded (HTTP 429) | Yes | | 4 | QuotaError | Monthly character quota exhausted (HTTP 456) | No | -| 5 | NetworkError | Connection timeout, refused, reset, or 503 Service Unavailable | Yes | +| 5 | NetworkError | Connection timeout, refused, reset, truncated response body, or 503 Service Unavailable | Yes | | 6 | InvalidInput | Missing or malformed arguments, unsupported format | No | | 7 | ConfigError | Configuration file or value invalid | No | | 8 | CheckFailed | A check-style command found actionable issues | No | | 9 | VoiceError | Voice API unavailable or session failed | No | | 10 | SyncDrift | `sync --frozen` detected translations out of date | No | | 11 | SyncConflict | `sync resolve` could not auto-resolve lockfile conflicts | No | -| 12 | PartialFailure | `deepl sync` completed with at least one failed locale | Yes (per-locale retry) | +| 12 | PartialFailure | `deepl sync` completed with at least one failed key, or a `deepl watch` session ended with a failed translation or auto-commit | Yes (per-locale retry) | ### Code details @@ -3208,6 +3514,14 @@ Remediation: run `deepl usage` to see remaining characters, or upgrade the plan Connection-layer failure or transient server outage. Covers TCP errors (`ECONNREFUSED`, `ENOTFOUND`, `ECONNRESET`, `ETIMEDOUT`, socket hang up), timeouts, proxy misconfigurations, and HTTP 503 responses. Also emitted for malformed or empty API responses thrown from `src/api/translation-client.ts` and `src/api/write-client.ts`, and from document/structured-file translation when the polling response is unparseable. +A response that came back without the placeholder tokens the CLI substituted for your variables counts as malformed here too: `translate` names the variables it lost (`{username}`, `%s`) and writes nothing rather than leaving its own internal tokens in your text. A directory run fails only the affected files and reports each one in the summary, so its exit code follows the usual batch mapping (`1` when nothing translated, `12` when some did). + +A response the endpoint cuts off mid-body is exit 5 as well, even though the status line said 200. The HTTP status is what decides it: a rejection carrying a 2xx response means the exchange was accepted and then failed while the body was read — truncated, or undecodable — so the message names the status and what went wrong (`Network error: the API answered HTTP 200 but its response body did not arrive intact: stream has been aborted`) rather than reporting an API error your input could fix. **The replay policy is unchanged by this.** A 200 says the server accepted, and may have billed, the request, so a truncated response to a `POST` (`translate`, `write`, a `sync` batch) is still sent exactly once; an idempotent `GET` is still replayed up to `--max-retries` times. + +A document upload response whose `document_id` is not a document identifier is rejected here as well. That value is interpolated into the path of every follow-up request (`POST /v2/document/{id}`, `POST /v2/document/{id}/result`), so an ID containing `..` or `/` would send this client's own requests to a different route on the endpoint. Anything outside `[A-Za-z0-9_-]+` is refused before the next request is sent, the ID is quoted back in the message, and no output file is written. + +A TMS that cannot be reached is exit 5 too, however it fails to answer. `deepl sync push` / `pull` used to report a refused connection or an unresolvable hostname as `Error: fetch failed` at exit 1 — the unclassified code — because `fetch` rejects with a bare `TypeError` and puts the errno on `cause`. Both now name the request and the code (`TMS request failed: PUT https://tms.example.com/api/projects/p/keys/greeting: ECONNREFUSED`), matching the client-side timeout, which was already exit 5. **The replay policy is unchanged by this**: retry eligibility is decided before the error is classified, so a refusal is still retried up to `tms.retry.max_attempts` and an unresolvable name still fails on the first attempt. + Remediation: check connectivity and `HTTPS_PROXY` / `HTTP_PROXY` env vars, then retry. #### 6 — InvalidInput @@ -3216,9 +3530,9 @@ User-supplied input was rejected by client-side validation before any API call. - `translate`: empty text, missing `--to`, unsupported file format, invalid `--tm-threshold` range, `--tm-threshold` without `--translation-memory`, `--translation-memory` without `--from`, mutually exclusive flags - `write`: empty text, `--style` and `--tone` used together, `--fix` without a file path, unsupported language for the Write API -- `voice`: missing target languages, unsupported plan (pre-flight check), invalid session parameters +- `voice`: unsupported target or source language code, unsupported `--content-type`, more than 5 target languages. A plan that does not include the Voice API is exit 9, not 6 — see [9 — VoiceError](#9--voiceerror) - `glossary`: missing name/entries, entry not found on delete -- `sync`: `--frozen` combined with `--force`, missing `.deepl-sync.yaml` (before `ConfigError` hands off) +- `sync`: `--frozen` combined with `--force`, `--watch` combined with `--force`, `--force` without `--yes` anywhere the confirmation prompt cannot be shown (piped stdin, cron, a git hook, `--no-input`, `CI=true`), missing `.deepl-sync.yaml` (before `ConfigError` hands off) - `hooks`, `watch`, `detect`, `admin`, `init`, `completion`, `cache`: argument parsing, unknown subcommand, bad path, bad size Remediation: re-read the command's `--help` and the relevant section of this API reference. @@ -3232,16 +3546,17 @@ The configuration file or a configuration value is invalid. Emitted by: - Any command that loads the config file when the file fails to parse, is missing a required field, or specifies an unsupported version - `sync` when `.deepl-sync.yaml` is missing required fields, has invalid locales, or declares an unsupported version - `sync push` / `sync pull` when the remote TMS returns 401/403 (surfaced as `ConfigError` with a hint to check `TMS_API_KEY` / `TMS_TOKEN` and the relevant YAML fields) +- `sync push` / `sync pull` when `.deepl-sync.yaml` names a `tms.server` hostname that is not in `tms.allowedServers` and the approval prompt is unavailable (`--no-input`, non-TTY) or declined - `glossary` when a named glossary cannot be resolved Remediation: run `deepl config get` to inspect the current config, or edit the file directly and re-run. #### 8 — CheckFailed -A check-style command ran successfully but found actionable issues. Exit is *soft* — `process.exitCode` is set so cleanup still runs. Emitted by: +A check-style command ran successfully but found actionable issues. Emitted by: -- `deepl write --check ` when the Write API would suggest changes (`needsImprovement === true`) -- `deepl sync validate` when validation surfaces one or more `error`-severity issues (missing placeholders, format-string mismatches, unbalanced HTML tags) +- `deepl write --check ` and `deepl correct --check ` when the Write API would suggest changes (`needsImprovement === true`). Under `--format json` the same run also emits the `ok: true` check result payload on stdout, so the count and the file are readable without parsing prose. Exit is *soft* here — `process.exitCode` is set so cleanup still runs +- `deepl sync validate` when validation surfaces one or more `error`-severity issues: a placeholder present in the source and **missing** from the translation, a mismatched ICU bracket nesting depth (`icu-brackets`), or a translated ICU argument name, format type or selector keyword (`icu-structure`) — plus a target file on disk that could not be read, reported under an `unusable_target` check while every other locale is still validated. Issues the validator raises at `warn` severity never affect the exit code: missing HTML tags (`html-tags`), *extra* placeholders the source does not have, a translation identical to its source (`untranslated`), and an outlying length ratio (`length-ratio`); a run with warnings and no errors exits 0. Exit is *hard* here — `process.exit(8)` is called as soon as the report is written, so do not rely on later cleanup running This code is specifically designed for CI use: a `check` step can block a merge without requiring try/catch wrappers in the calling script. It does **not** indicate a CLI failure. @@ -3252,6 +3567,7 @@ Voice API call failed for a reason other than authentication, rate limiting, or - `deepl voice` when the plan does not include the Voice API (pre-flight check in the voice client) - Voice streaming URL validation failures (`src/api/voice-client.ts`: non-`wss://` scheme, unparseable URL, disallowed host) - Voice session lifecycle errors (failed to open, unexpected close) +- `deepl voice` when the stream ends with the source transcribed but no translation for a requested target language Remediation: confirm Pro/Enterprise plan, verify the session configuration, and retry. @@ -3273,7 +3589,15 @@ Remediation: open `.deepl-sync.lock`, resolve the remaining `<<<<<<<` / `======= #### 12 — PartialFailure -`deepl sync` completed, but at least one locale failed while at least one other locale succeeded. The successful locales' target files and lockfile entries are written; the failed locales' files are not touched. Emitted only by `deepl sync` (the root command). +`deepl sync` completed, but at least one key failed to translate. This covers both a locale that failed entirely and a locale that translated some of its keys and failed the rest — a failed key is absent from the written target file, so any failure count above zero means the run did not produce a complete result. The successful translations' target files and lockfile entries are written; a locale that failed entirely has its file left untouched. Emitted only by `deepl sync` (the root command). + +A key whose translation failed placeholder/ICU validation counts as failed here too: it is withheld from the target file rather than written corrupt. See [`validation`](SYNC.md#validation) in the sync configuration reference. With `validation.fail_on_error: true` the same run raises `ValidationError` (exit 6) instead. + +A key that translated successfully but that the target file's format could not be given a slot for also counts as failed. Every key is read back out of the content just written before the lockfile is updated, so the run reports it rather than recording a translation the file does not contain. See [A string added after the first sync](SYNC.md#a-string-added-after-the-first-sync). + +A locale whose **target file is on disk but could not be read or parsed** fails here as well, and fails before any translation is requested, so nothing is billed for it and the file is not written. That file is the only copy of its locale's translations, since the lockfile records source hashes rather than translated text. See [A target file that cannot be read](SYNC.md#a-target-file-that-cannot-be-read). + +`deepl watch` uses the same code when the session ends: on Ctrl+C (SIGINT/SIGTERM) it exits 12 rather than 0 if it recorded any failed translation or any failed `--auto-commit`, so a script driving a watch session can tell a clean run from one that lost work. The counts are printed beside the translation total before the process exits. Authentication failures (401/403) abort the entire run and surface as exit code 2 (`AuthError`) instead of 12. Network / rate-limit / quota failures bubble up as 5 / 3 / 4 respectively. Code 12 specifically means "the run proceeded far enough to attempt per-locale work, and the result was mixed." @@ -3342,10 +3666,13 @@ deepl write --check README.md ## See Also +- [Migrating from 1.x to 2.0.0](MIGRATION.md) — removed flags, exit codes that moved, output that moved to stdout +- [Sync configuration reference](SYNC.md) +- [Troubleshooting](TROUBLESHOOTING.md) - [Examples](../examples/) - [DeepL API Documentation](https://www.deepl.com/docs-api) --- -**Last Updated**: July 29, 2026 +**Last Updated**: August 9, 2026 **DeepL CLI Version**: 2.0.0 diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 00000000..19238503 --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,359 @@ +# Migrating from 1.x to 2.0.0 + +This release removes deprecated flags, changes several exit codes, and moves +machine-readable output from stderr to stdout. Nothing here is a silent change of +meaning: every item below either fails loudly or changes a value you can see. + +**If you only read one section, read [Exit codes that moved](#exit-codes-that-moved).** +It is the change most likely to alter what your CI pipeline decides, and unlike a +removed flag it does not announce itself. + +## Contents + +- [Requirements](#requirements) +- [Removed flags and config keys](#removed-flags-and-config-keys) +- [Exit codes that moved](#exit-codes-that-moved) +- [Machine-readable output moved to stdout](#machine-readable-output-moved-to-stdout) +- [Output that scripts parse](#output-that-scripts-parse) +- [Files written to disk](#files-written-to-disk) +- [Tagged translation output](#tagged-translation-output) +- [TypeScript consumers](#typescript-consumers) +- [Upgrade checklist](#upgrade-checklist) + +## Requirements + +**Node.js 24.15.0 or later** — up from 20. The cache now uses Node's built-in +`node:sqlite` instead of the `better-sqlite3` native addon, so there is no +compilation step and no `ERR_DLOPEN_FAILED` after a Node upgrade, but the runtime +floor is higher. 24.15.0 is the release where `node:sqlite` stopped emitting an +`ExperimentalWarning`, which would otherwise reach stderr on every cache-backed +command. Running under an older Node fails fast with a one-line error and exit 6 +rather than crashing later. + +**The package is published as `@deepl/cli`.** The command is still `deepl`. + +```bash +# 1.x +npm install -g deepl-cli + +# 2.0.0 +npm install -g @deepl/cli +``` + +Your cache and config are untouched by the rename. The cache database is upgraded +in place on first open: rows whose keys can no longer be reached are dropped and +every other namespace is left alone, so the first translation after upgrading may +be a miss where it used to be a hit. + +## Removed flags and config keys + +All of these fail immediately, so nothing here can pass silently. + +| Removed | Replacement | Symptom | +| --- | --- | --- | +| `translate --enable-beta-languages` | Delete it — beta languages are part of the regular language set | `error: unknown option`, exit 6 | +| `sync init --source-lang` | `--source-locale` | `error: unknown option`, exit 6 | +| `sync init --target-langs` | `--target-locales` | `error: unknown option`, exit 6 | +| `tms.auto_push` | Run `deepl sync push` after `deepl sync` | `ConfigError`, exit 7 | +| `tms.auto_pull` | Run `deepl sync pull` before `deepl sync` | `ConfigError`, exit 7 | +| `tms.require_review` | Preview with `deepl sync pull --dry-run` | `ConfigError`, exit 7 | + +```bash +# 1.x +deepl sync init --source-lang en --target-langs es,fr + +# 2.0.0 +deepl sync init --source-locale en --target-locales es,fr +``` + +The three `tms:` keys were on the config allowlist and in the documentation but no +code ever read them, so a review gate you configured was doing nothing. They are +rejected by name — with the replacement in the error's suggestion — rather than as +unknown fields, so the message cannot be mistaken for a typo. + +```yaml +# 1.x — accepted and ignored +tms: + enabled: true + server: https://tms.example.com + auto_push: true + auto_pull: true + require_review: true + +# 2.0.0 — remove all three; push explicitly instead +tms: + enabled: true + server: https://tms.example.com +``` + +Also gone: the `usage` command's "Speech-to-Text Usage" section and +`speechToTextMilliseconds*` fields, following the API's deprecation of +`speech_to_text_milliseconds_count`/`_limit`. + +### `.deepl-sync.yaml` is validated more strictly + +A config 1.x accepted can now be refused at load, which fails every `sync` subcommand +rather than one run. All of these exit 7 with the offending value named: + +| Now refused | Fix | +| --- | --- | +| A `source_locale` or `target_locales` entry that is not a BCP-47 tag (1.x checked only three forbidden substrings) | Spell the locale as a language tag: `en`, `pt-BR`, `zh-Hans` | +| A `target_path_pattern` containing a `.git` or `.github` path segment | Move the target out of those directories | +| A target path that begins with `-` | Rename it, or prefix the pattern with `./` | +| A bucket `include` glob that resolves outside the project root | Keep globs inside the repository | +| `--locale ` not listed in `target_locales` | Add it to `target_locales`, or fix the spelling | + +**`.deepl-sync.yaml` is discovered only up to the repository boundary.** 1.x walked to the +filesystem root, so a config in an ancestor directory outside the repo was adopted as +project root. If yours lived there, `sync` now reports no config at all — move it inside +the repository. + +## Exit codes that moved + +Most of these are conditions that used to exit 0 while losing or skipping work, +saying so only in a log line. A few move the other way, where 1.x failed on input it +should have accepted. In both directions the old code was the wrong answer. + +| Command | Condition | 1.x | 2.0.0 | +| --- | --- | --- | --- | +| any command | Unknown subcommand, unknown option, invalid `--choice` value, missing argument | 1 | 6 | +| `sync` | Target file unreadable or unparseable | 0 | 12 | +| `sync` | Key could not be written into the target | 0 | 12 | +| `sync` | Validation error on a translation | 0 | 12 | +| `sync` | File containing an empty string value | 12, every run | 0 | +| `sync validate` | Target file cannot be read | 1 | 8 | +| `sync validate` | PO/XLIFF translation with a placeholder or ICU error the check previously could not see | 0 | 8 | +| `sync push` / `pull` | TMS unreachable | 1 | 5 | +| `sync --force` | Cannot prompt (piped stdin, cron, hook, `--no-input`) | 0 | 6 | +| `watch` | Session recorded any failure | 0 | 12 | +| `watch --auto-commit` | Output directory in no git repository | 0 | 6, at startup | +| `translate` | Translation lost one of your placeholders | 0, with output | 5 | +| `translate ` | File containing an empty string value | 1 | 0 | +| `translate ` | Rate limit part-way through a structured file | 1 | 3 | +| `write --check --format json` | Text needs no changes | 8, always | 0 | +| `translate ` | Every file in the directory failed | 0 | 12 | +| `translate ` | Stopped by one request-level rejection | 1 | That rejection's code (2, 4, 6) | +| `translate ` | Structured file above the new size ceiling | 0 | 6 | +| any command | Client-side timeout, or a response body cut off mid-send | 6 | 5 | +| `sync` | One locale failed completely while others succeeded | 0 | 12 | +| `sync` | `--concurrency` value is not a number | 0 | 6 | +| `sync` | `--locale` value not listed in `target_locales` | 0 | 7 | +| `voice` | Audio transcribed but a requested `--to` produced no translation | 0 | 9 | +| any command | Interrupted with Ctrl-C | 0 | 130 | + +Four of these deserve a note: + +- **Every parse error is now exit 6, not exit 1.** An unknown subcommand, an unknown + option, an out-of-range `--choice` value and a missing argument all exited 1 in 1.x, + indistinguishable from a crash. Anything branching on exit 1 to mean "the CLI itself + failed" must now treat 6 as "I invoked it wrong" and keep 1 for genuinely unclassified + failures. This is also the code the removed flags above report. + +- **`sync --force` now needs `--yes` anywhere it cannot prompt**, not only under + `CI=true`. Add `--yes` to any invocation from a git hook, cron job, `make` + target or container entrypoint. +- **`write --check --format json` could never pass in 1.x** — the verdict was + computed against a rendered JSON document. A gate built on it was either + unconditionally red or green only because a later step ignored the code. It now + returns the truthful answer, which means it will start passing. +- **`translate` with a lost placeholder now writes nothing and exits 5.** In 1.x + it wrote output containing the CLI's own internal token, such as `__ Var_0 __`. + +Exit 3 and 5 are retriable; 12 is a partial failure with some locales succeeded; 130 is +an interrupt, not a failure of the work. A client-side timeout used to report 6, which is +*not* retriable — so a pipeline that gave up on 6 will now retry it. The full table is in +[API.md](./API.md#exit-codes). + +## Machine-readable output moved to stdout + +**Under `--format json`, a failing command writes its error envelope to stdout.** +This applies to every command with a JSON mode — `translate`, `write`, `correct`, +`voice`, `usage`, `languages`, `detect`, `glossary`, `tm`, `cache`, `config`, +`hooks`, `admin`, `style-rules` — and to every `sync` subcommand. + +```bash +# 1.x — reason on stderr, payload on stdout, two redirections +deepl translate "hi" --to es --format json > out.json 2> err.txt + +# 2.0.0 — one stream carries both +deepl translate "hi" --to es --format json > out.json +``` + +The exit code is still the failure signal. Warnings stay on stderr, but 2.0.0 emits +warnings 1.x did not, so a check that treats *any* stderr output as failure will start +tripping. Three are new and unconditional: + +- **A non-DeepL `--api-url` or `api_url` is announced** before the key is sent, loopback + included — so local mocks and self-hosted proxies see it on every run. +- **A `--lang` shaped like a language tag but absent from the bundled Write list** notes + that it is deferring to the API instead of rejecting it. +- **A `config.json` looser than `0600`** is repaired to `0600` with a note suggesting you + rotate the key. The settings in it are still honoured. + +Anything **parsing** stderr for a failure reason must switch to stdout. A human reading +`--format json` output will see the envelope's `message` and `suggestion` fields where a +prose sentence used to be. + +`config get` and `config list` default to `json`, so their failures carry the +envelope with no flag passed. + +Separately, the human-readable reports of `sync status`, `sync validate`, +`sync audit`, `sync init` and `auth show` now print to stdout, so `> report.txt` +captures them. + +## Output that scripts parse + +**Language codes are lowercase everywhere.** 1.x mixed three casings: `languages` +printed lowercase, `glossary show` and `tm list` uppercased at display time, +`translate --format table` uppercased the target, and `write`/`correct` used BCP-47 +(`en-GB`, `zh-Hans`). Anything comparing codes scraped from output needs to fold +case or expect lowercase: + +| Command | 1.x | 2.0.0 | +| --- | --- | --- | +| `glossary show` | `Source language: EN` | `Source language: en` | +| `glossary show` | `EN → ES: 5 entries` | `en → es: 5 entries` | +| `tm list` | `brand-terms (EN → DE, FR)` | `brand-terms (en → de, fr)` | +| `translate --format table` | row labelled `DE` | row labelled `de` | +| `write --format json` | `"language": "en-US"` | `"language": "en-us"` | + +**No command line has to change** — input is case-insensitive everywhere. `voice` +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; it now accepts both. + +Wire parameters that are not display output are untouched: `translate` and the +glossary create endpoint still send uppercase `source_lang`/`target_lang`, as those +endpoints document. + +`glossary create` also prints its success line to stdout and renders the creation +timestamp as a locale-independent ISO string rather than a locale-dependent date on +stderr. + +**`hooks list --format json` reports a state string, not a boolean.** The values are +`installed`, `modified`, `unverified` and `not-installed`. A truthiness test now +passes for every state including `not-installed`, so it must be replaced: + +```js +// 1.x +if (hook.installed) { /* ... */ } + +// 2.0.0 +if (hook.state === 'installed') { /* ... */ } +``` + +A hook you edited by hand reports `modified` from then on, since its body no longer +matches the hash recorded at install. + +**`write --alternatives --format json --output ` writes JSON.** It wrote the +numbered prose list in 1.x. To keep prose in the file, drop `--format json`. + +**Ten language display names changed** to match the API, a consequence of +generating the language list rather than hand-writing it. + +**`sync` JSON shapes gained fields and skip reasons.** A consumer iterating +`skipped` will meet reasons it has not seen: `shared_target`, `plural_entry`, +`unusable_target`, `untranslated` and `needs_review`. Each locale in +`sync status --format json` gains a `needsReview` count, and pulled keys no longer +carry `review_status`. + +**Coverage numbers drop for PO and XLIFF projects.** `sync status` now counts a +`#, fuzzy` PO entry and an XLIFF `needs-review-translation` target as needing +review rather than as complete — which is what `msgfmt` has reported all along. A +project reported at 100% will drop to the share actually shippable, and +`sync push` reports a correspondingly lower pushed count. Nothing is re-translated +or re-billed, and `sync --frozen` still passes. + +`sync --dry-run` may also report a **larger** character estimate than 1.x, because +it now counts repair work — keys the lockfile calls translated that the target file +no longer holds — which a real run bills. If you tuned `sync.max_characters` +against the old under-count, raise it to the number `--dry-run` now reports. + +## Files written to disk + +**Backups are `.deepl.bak`, not `.bak`.** The stale-backup sweep only +considers the new suffix, so any `.bak` files left by 1.x stay on disk untouched — +delete them yourself once you no longer need them. + +**`watch` writes a nested source file to a nested output path.** Watching `docs/` +with `--output out`, the file `docs/guide/intro.md` now lands at +`out/guide/intro.es.md` where it used to land at `out/intro.es.md`. This matches +what `deepl translate --output ` has always produced. A file at the top +of the watched directory, and a watched path that is a single file, are unchanged. + +**`sync` writes `state="translated"` on XLIFF targets** whose translation it +replaced, where it used to leave the old value. A target that carried no `state` is +written exactly as before. + +**`sync resolve` now takes the newer translation, as documented.** 1.x kept the local side +of every conflict regardless of `translated_at`, so a teammate's newer translation was +discarded silently. The same conflict may now resolve the other way. If you have been +relying on resolve-keeps-mine, review the first few resolves after upgrading. + +**A YAML source file built on anchors and aliases yields a different key set.** Aliases are +no longer expanded; aliased content is translated once, at its anchor. If your catalog uses +`<<:` merges or repeated aliases, re-check `sync status` counts after the first run. + +**An Android XML translation containing `]]>` is refused rather than written.** It used to +land inside a CDATA section, where it closed the section early. The key is withheld and +counted as failed, so the run reports it. + +## Tagged translation output + +**`--tag-handling` now pins `tag_handling_version=v2`** instead of letting the API +choose. Tagged output may differ from 1.x. To restore the old behaviour: + +```bash +deepl translate input.html --to es --tag-handling html --tag-handling-version v1 +``` + +## TypeScript consumers + +If you import this package's types, two published shapes changed. The `Language` +union grew from 121 to 125 members with nothing removed, so it needs no action — +but `WriteLanguage` did lose members. + +**`WriteLanguage` members are lowercase.** Five regional codes were re-spelled: + +| 1.x | 2.0.0 | +| --- | --- | +| `'en-GB'` | `'en-gb'` | +| `'en-US'` | `'en-us'` | +| `'pt-BR'` | `'pt-br'` | +| `'pt-PT'` | `'pt-pt'` | +| `'zh-Hans'` | `'zh-hans'` | + +Code that passed a literal in the old casing no longer compiles: + +```ts +// 1.x +const target: WriteLanguage = 'en-GB'; + +// 2.0.0 +const target: WriteLanguage = 'en-gb'; +``` + +**`WriteImprovement.targetLanguage` widened from `WriteLanguage` to `string`.** The +API echoes this field in its own casing (`en-GB`, `zh-Hans`), which the narrower +type claimed it would not. Assignments *from* the field still compile; code that +assigned it *to* a `WriteLanguage` variable needs a check or a cast. + +## Upgrade checklist + +1. Move to Node 24.15.0 or later and reinstall from `@deepl/cli` (or `brew install deepl/tap/deepl`). +2. Remove `--enable-beta-languages`; rename `sync init --source-lang`/`--target-langs`. +3. Delete `tms.auto_push`, `tms.auto_pull` and `tms.require_review` from `.deepl-sync.yaml`. +4. Add `--yes` to any non-interactive `deepl sync --force`. +5. Re-check every exit-code branch in CI against the table above — especially any + step treating `sync` or `watch` exit 0 as "complete". +6. Point JSON error parsing at stdout instead of stderr. +7. Fold case when comparing language codes from output; replace + `hook.installed` with `hook.state === 'installed'`. +8. Expect lower `sync status` coverage for PO and XLIFF projects, and re-tune + `sync.max_characters` against the new `--dry-run` estimate. +9. Follow `watch --output` into its new nested layout, and clean up leftover + `.bak` files. +10. If you import the types, lowercase your `WriteLanguage` literals. + +The complete list of changes, including everything fixed that does not require +action, is in +[CHANGELOG.md](https://github.com/DeepL/deepl-cli/blob/main/CHANGELOG.md). diff --git a/docs/SYNC.md b/docs/SYNC.md index d9b91adf..6baaf5f9 100644 --- a/docs/SYNC.md +++ b/docs/SYNC.md @@ -50,12 +50,49 @@ deepl sync The lockfile tracks content hashes for every source string. On subsequent runs, only strings whose hash has changed (or that are newly added) are sent to DeepL. Deleted keys are removed from target files. This makes sync fast and cost-efficient -- you only pay for what actually changed. +A key whose source value is an empty string is written to each target as an empty string without a request. It counts as translated, so it settles in the lockfile on the first run instead of being retried on every run. The same holds for an empty ICU plural branch such as `one {}` -- the branch stays empty and the rest of the message is translated normally. + All sync commands (`sync`, `sync push`, `sync pull`, `sync export`, `sync validate`) refuse to follow symbolic links when scanning `include` globs. A symlink matching a bucket pattern is silently skipped, preventing a hostile symlink (e.g., `locales/en.json` -> `/etc/passwd`) from exfiltrating files outside the project root to the TMS server or into an exported XLIFF. +Sync also refuses to read or write any path resolving into `.git/` or `.github/`, whether it arrives as an `include` match or as a resolved target path. Translating a file into `.github/workflows/` would turn a target-locale filename into CI workflow code whose body came from the translation endpoint, so a bucket rooted there fails with exit 6 rather than being silently skipped. The check compares segments relative to the project root, so a checkout that itself lives under a `.github` directory is unaffected. + ### Concurrent sync Only one `deepl sync` run is supported at a time per project directory. At startup, sync writes a `.deepl-sync.lock.pidfile` containing its PID; a second invocation that sees an existing pidfile whose PID is still alive exits with `ConfigError` (exit code 7). If the PID is dead (e.g., a previous run crashed), sync removes the stale pidfile with a warning and proceeds. The pidfile is deleted automatically on normal completion and on SIGINT/SIGTERM. +Three verdicts are possible for the PID a pidfile names, because `kill(pid, 0)` has three answers. A PID that is running holds the lock. A PID that does not exist is stale, and sync removes the pidfile with a warning and proceeds. A PID that exists but belongs to **another user** cannot be probed any further — and that is also what a PID recycled by an unrelated process looks like — so it is trusted only while the start time recorded in the pidfile keeps it plausible: a holder this user cannot signal whose recorded start is more than 24 hours old (or is not a readable date, or is impossibly far in the future) is treated as stale and reclaimed with a warning naming it. A holder that *is* running is never aged out, however old the lock: taking the lock from a live sync is the concurrent-writer hazard the lock exists to prevent. + +For that last case — a lock you know is dead but the machine still reports as alive, typically a recycled PID belonging to you — pass `--break-lock`: + +```bash +deepl sync --break-lock # also on `sync pull` and `sync resolve` +``` + +It removes the pidfile whatever its holder looks like, prints the PID and start time it removed, and takes the lock. Removing `.deepl-sync.lock.pidfile` by hand does the same thing. Both are unsafe if that sync really is running: two concurrent runs write the same target files and then overwrite each other's lockfile. `--break-lock` applies to the run you pass it to only — a `--watch` session breaks the lock for its first pass and then arbitrates normally for the rest of the session. + +Every command that writes `.deepl-sync.lock` takes the lock: `deepl sync`, `deepl sync pull` and `deepl sync resolve`, the last of these including `--dry-run`. A run reads the lockfile when it starts and writes its whole in-memory copy when it finishes, so anything written in between would be replaced. Read-only commands (`sync status`, `sync validate`, `sync audit`, `sync export`) do not take it. + +If the lockfile changes on disk between a run's read and its write anyway — a hand edit, a `git merge` or another tool, none of which take the lock — the run says so and still writes what it recorded: + +``` +.deepl-sync.lock changed on disk since this run read it; overwriting it with what this run recorded. +Check the file into git before re-running if another tool or a merge wrote it. +``` + +It warns rather than refusing because by that point the target files are written and their translations billed; declining to record them would leave the next run to translate and bill all of them again. + +### Two configurations writing one file + +A rewrite emits exactly the keys its own configuration defines, so a target file has one owner. Two configurations can end up sharing one anyway when their project roots nest — a repository-level `.deepl-sync.yaml` and a package-level one whose `target_path_pattern` resolves into the same directory, or two config files in one directory used with `--sync-config`. Nothing about the process lock helps here: it is keyed on the project root, so nested configurations take different pidfiles, and even run strictly one after another they would delete each other's keys and re-translate them on every run — measured as a target alternating between the two key sets, at exit 0 throughout, with each run reporting success. + +A run that is about to delete keys from a target now looks for another sync configuration that accounts for them. The evidence is the other `.deepl-sync.lock`: a configuration writes only inside its own project root, so one that also writes this file is rooted at one of the file's ancestor directories, and a match requires that its lockfile record those exact keys for the same locale. When it does, the file is left exactly as it stands: + +- `deepl sync` fails that locale — nothing written, nothing billed for it, the keys recorded `failed` so the next run retries them — and the run ends with exit code 12. +- `deepl sync pull` skips that locale under the `shared_target` reason. + +Both name the other lockfile, the shared keys and the remedy: give each configuration its own target file, or merge them into one. Keys someone added to a locale file by hand are unaffected — no lockfile accounts for them, so they are still pruned with the usual warning. If the lockfile named is left over from a configuration that no longer writes the file, remove it. + + ## Supported File Formats | Format | Extensions | Used By | @@ -83,11 +120,173 @@ All parsers preserve format-specific metadata: - **ARB**: `@key` metadata (description, placeholders, type) - **XLIFF**: `` elements, state attributes, translation units - **TOML**: `#` comments, blank lines between sections, key order within a section, per-value quote style (double-quoted vs literal single-quoted), irregular whitespace around `=`, and every byte outside a replaced string literal round-trip verbatim via span-surgical reconstruct. Multi-line triple-quoted strings are passed through as-is (not translated). -- **Properties**: comments, Unicode escapes (`\uXXXX`), line continuations, separator style +- **Properties**: comments, Unicode escapes (`\uXXXX`), line continuations, separator style. A trailing backslash continues the line only when the run of backslashes ending it is odd, so a value ending in a literal `\` (written as `\\`) round-trips instead of swallowing the next entry. - **Laravel PHP arrays**: PHPDoc/line/block comments, quote style (single vs double), trailing commas, irregular whitespace, and every byte outside a replaced string literal round-trip verbatim. Span-surgical reconstruct — the AST is used for string-literal offsets only, never reprinted. Allowlist rejects double-quoted interpolation (`"Hello $name"`), heredoc, nowdoc, and string concatenation; Laravel pipe-pluralization values (`|{n}` / `|[n,m]` / `|[n,*]`) are excluded from the translation batch and counted separately in `deepl sync status`. The sync engine also supports **multi-locale formats** where all locales are stored in a single file (e.g., Apple `.xcstrings`). For these formats, the engine automatically serializes locale writes to prevent race conditions and passes the locale to the parser so it can scope extract/reconstruct operations to the correct locale section. +### Control characters in a translation + +No writer emits a raw C0 control byte. Tab, newline and carriage return are escaped or legally emitted as usual; every other byte below U+0020 is either escaped or refused, because written raw it would survive into git — where `git diff`, `cat`, `less` and CI log viewers render an ESC sequence as a live terminal command — and because two of the formats cannot read it back: + +| Format | Behaviour | +|--------|-----------| +| JSON, YAML, ARB, Xcode String Catalog | Escaped by the underlying emitter | +| TOML | Escaped as `\uXXXX`; a literal (single-quoted) string is promoted to the double-quoted form, which is the only one with an escape. U+007F is escaped too — TOML rejects it raw | +| Java Properties | Escaped as `\uXXXX`, extending the existing rule for non-ASCII | +| iOS Strings | Escaped as `\UXXXX` | +| Gettext PO | `\r` escaped; anything else in C0 is refused, since the PO escape set cannot spell it | +| Android XML, XLIFF | Refused. XML 1.0 has no escape and no numeric character reference for these bytes, so the file would stop being well-formed and `aapt2` or a CAT tool would reject it | + +A refusal is a `ValidationError` naming the resource or entry and the codepoint (`U+001B`) — never the byte itself, which prints as nothing. + +### A string added after the first sync + +The first sync has no target file, so the **source** file is the template every translation is written into, and every key has a slot. From the second run onwards the **target** file is the template -- that is how the translations it already holds survive -- and a key added to the source since has no slot in it. Every parser writes such an entry rather than dropping it, in the layout the target file already uses: + +| Format | Where the new string is written | +|--------|---------------------------------| +| JSON, YAML, TOML, ARB, Xcode String Catalog, Gettext PO | Appended by the underlying emitter, at the detected indentation | +| Java Properties, iOS Strings | Appended as a line, keeping the file's trailing newline and leaving any dangling comment where it was | +| Laravel PHP | Spliced into the array that owns the key, matching the file's quote style, indentation, one-line-or-many layout and trailing-comma style. `array(...)` and `[...]` are both handled | +| Android XML | A ``, or a whole `` block, before `` | +| XLIFF | A `` with `` and `` in 1.2, a `` in 2.0, carrying the namespace prefix of the unit it is written beside | + +Two cases are deliberately left out, because writing them would mean inventing structure the source file already defines: a new **`` item** in Android XML, whose `name.index` key cannot be told apart from a plain resource whose name ends in a dot-integer; and a **Laravel PHP key whose parent array is absent** from the target file. Add the containing element to the target file once and the key is written on the next sync. + +Whatever the reason a key does not land, the run says so rather than recording it as translated. Every key is read back out of the content just written before the lockfile is updated, so a key the file does not hold is recorded `failed`: the run warns naming the file, the locale and the key, reports `✗ es: 0/1 keys` and exits **12** (`PartialFailure`), `deepl sync status` counts it against the locale, and `deepl sync --frozen` exits **10**. The next sync retries it. + +**A target file is also checked against what the lock file claims about it.** For every key the lock file records as `translated` against the current source text, `deepl sync status` and `deepl sync --frozen` open the locale's target file and check that the translation is in it. A key it does not hold is counted **`unwritten`** — a category of its own, distinct from `missing` (absent from the lock file, or recorded as a failed attempt) and `outdated` (recorded against an older source), and never counted as complete: + +``` +$ deepl sync status +Source: en (2 keys) + + es [##########..........] 50% (0 missing, 0 outdated, 1 unwritten) + +1 key is recorded as translated in the lock file but is not in the target file +— es: locales/es.properties ("added"). Run `deepl sync` to translate it again. +``` + +`--frozen` treats it as drift and exits **10**; `deepl sync` no longer returns early on a project in that state, so the key is translated again and written. This catches a target file damaged by anything outside the CLI as well — a bad merge, a partial checkout, a hand deletion, a `git checkout` of an older locale file. + +A target file that **cannot be read or cannot be parsed** also has all of its claimed keys counted `unwritten` — a file whose contents are unavailable cannot be shown to hold them — but it is reported as its own case, because neither half of the sentence above applies to it: nobody has been shown what it holds, and `deepl sync` refuses to rebuild it (see [A target file that cannot be read](#a-target-file-that-cannot-be-read)). + +``` +$ deepl sync status +Source: en (2 keys) + + es [....................] 0% (0 missing, 0 outdated, 2 unwritten) + +2 keys are recorded as translated in the lock file for a target file that could +not be read — es: locales/es.json (JSON: 'menu.save' is the key of two different +strings. …). Fix the file: `deepl sync` will not overwrite it. +``` + +In `--format json` the locale's entry in `unwrittenByLocale` carries an extra `unusable` field holding that reason; an entry without the field is a file that was read and genuinely lacks the keys. + +Two deliberate exemptions. A key whose **source value is empty** is never reported: an empty source can only produce an empty translation, and PO and XLIFF read an empty translation side as untranslated on purpose (see [Bilingual formats](#bilingual-formats-po-and-xliff)), so such a key is legitimately absent from a bilingual target. A key the lock file records as `failed`, or against an older source hash, is already reported as outdated and is not counted twice. + +The cost is one read and parse of each target file per locale, and it is only paid where the answer would otherwise come from the lock file alone: `sync status`, `sync --frozen`, `sync --dry-run`, the `sync.max_characters` cost cap, and a `sync` run that has nothing else to do. A run with translating to do reads those files anyway. Measured on a 2.8 MiB XLIFF source with 20,316 keys across 6 locales (52 MiB of files): `sync status` 410 ms → 660 ms, `sync --frozen` 465 ms → 970 ms. A locale with no keys claimed by the lock file is not read at all, so a project mid-first-sync pays nothing. + +Within one source file the answer is computed once and reused, so no command reads a target file twice for it. On a 20,000-key, 6-locale JSON project (2.83 MiB source, 17.3 MiB of target files) that took `sync --frozen` from 1.13 s to 0.79 s, because a healthy project made it ask twice — once for the drift check and again for the "nothing to do" check. + +### A plural entry carried forward + +A run rewrites a whole target file even when only one key changed — that is how the file stays consistent — so every entry the run is *not* translating is carried forward. For an entry with plural forms (gettext `msgstr[N]`, an Android `` element's items) the forms the target file holds are kept exactly as they stand: on a sync triggered by a sibling key, when a re-translation was withheld by validation, and on `deepl sync pull`. Only translating the entry itself — its source text changed, or it is new to the locale — rewrites the forms. + +A TMS export carries one string per key, which cannot fill a plural entry's forms, so `deepl sync pull` never applies an exported value to one: the key is reported under the `plural_entry` skip reason in the `(N skipped: …)` summary and in `--format json`, is not counted as pulled or replaced, and no lockfile entry is recorded for it. The entry stays exactly as the target file holds it; review plural strings in the local file, or empty the forms to have the next `deepl sync` translate them afresh. + +### A target file that cannot be read + +Every command that opens a locale's target file distinguishes three answers, and only the first two let it write: + +| The file is | What follows | +| --- | --- | +| **not there** (`ENOENT`) | The locale has nothing yet. `sync` writes it from the source template; `pull` creates it. | +| **there and parseable** | Its translations are merged with the run's, so anything the run does not touch survives. | +| **there and unreadable** — any other errno (`EACCES`, `EISDIR`, `EIO`), or content the parser refuses | Nothing is translated, nothing is billed, and the file is not written. | + +The third case matters because the lock file stores a hash of each source string and not the translated text, so a target file is the only copy of its locale's translations. Treating it as empty means re-translating and re-billing every key and then writing the result over that copy. + +`deepl sync` therefore fails that locale for that file, before requesting any translation: + +``` +$ deepl sync --yes +Sync failed for locale "es" on "locales/en.json": target file locales/es.json is +on disk but could not be read (JSON: 'menu.save' is the key of two different +strings. …) — it holds the only copy of this locale's translations, so it was left +as it stands rather than rebuilt from the source. Fix it, then run `deepl sync` +again. + ✗ es: 0/2 keys (locales/en.json) +Sync complete: 2 current (2 translations failed) +``` + +Exit **12** (`PartialFailure`), 0 characters billed, and the file is byte-identical. Other locales of the same file, and other files, are unaffected. A key the source has just gained is recorded `failed` rather than `translated`, so the next run retries it once the file is fixed. `deepl sync status` reports the file under its own sentence (see above) and `--frozen` exits 10. + +`deepl sync --dry-run` says so before you get there, rather than leaving the exit 12 to the real run: + +``` +$ deepl sync --dry-run +es: target file locales/es.json is on disk but could not be read (JSON: +'menu.save' is the key of two different strings. …) — it holds the only copy of +this locale's translations, so a real run would leave it as it stands, translate +nothing for this locale and exit 12. It is left out of the estimate below. Fix it +before running `deepl sync`. +[dry-run] No translations performed. +``` + +The locale is left out of the character estimate because the run bills nothing for it. Dry run itself still exits **0** and writes nothing: it reports what a run would do rather than standing in for its exit code, which is what `--frozen` is for. + +`deepl sync pull` skips the locale under the `unusable_target` skip reason — or `key_collision` when that is the cause, which has its own remediation (see below) — and leaves the file exactly as it stands. `deepl sync push` reports a file that is not there under `target_missing` and surfaces every other failure, since it only reads. + +`deepl sync validate` reports the file as an error-severity issue under an `unusable_target` check and goes on to validate every other locale and file. A file nobody could read holds translations nobody validated, so skipping it with a warning would let the command exit 0 — CI green — over an unvalidated locale; the issue counts toward exit **8** like any other error. In `--format json` it appears in `issues` with the target path as its `key` and `file`, an empty `source`/`translation`, and the reason in its message; such a file's keys are not counted in `totalChecked` or `passed`, which keep meaning "pairs actually compared". A locale with no target file at all is still skipped silently — never having been synced is not a validation failure. `deepl sync audit` excludes the locale from the comparison and lists it under `missingTargets` at its normal exit code: audit is a report, not a gate. + +The distinction is deliberately strict: an errno the CLI does not recognise refuses the file rather than admitting it. A file that is genuinely absent produces `ENOENT` and nothing else. + +### Key separators and colliding keys + +Every parser reduces a string's position in the file to a flat key, using a separator the format does not otherwise carry: PO joins `msgctxt` and `msgid` with U+0004, YAML joins path segments with U+0000, and JSON, Laravel PHP and Android XML join with `.`. A key component containing that separator makes two different strings share one key, and there is no correct way to write them back — one translation lands in the other's slot. + +Such a file is **skipped with a warning naming the colliding key**, and the rest of the run continues at its normal exit code, the same way a file exceeding `limits.max_depth` is skipped. For PO and YAML the reserved byte is refused outright, and quoted back escaped (`\u0004`, `\u0000`) rather than printed, since it renders as nothing in a terminal or a diff. For the three `.`-separated formats the refusal triggers on two entries resolving to one key, which in those formats can only happen via the separator. + +`.properties` and XLIFF are exempt: a literally repeated key is legal in both (`Properties.load` is last-wins), so a repeat there is not evidence of a collision. + +On `deepl sync pull`, a **target** file whose keys collide is left exactly as it stands and reported under the `key_collision` skip reason. Pull does not fall back to rebuilding it from the source file, which would discard every local translation the TMS export does not carry. + +### Bilingual formats: PO and XLIFF + +Nine of the eleven formats are monolingual -- a target file holds translations and nothing else, so its values *are* the translations. Gettext PO and XLIFF are **bilingual**: one file carries both sides, the source in `msgid` / `` and the translation in `msgstr` / ``. Every sync path that needs "the translation this target file already holds" therefore reads the translation side, never the source side: + +| Path | What it reads from a PO / XLIFF target | +|------|----------------------------------------| +| `deepl sync` | The existing `msgstr` / `` is carried forward for a key the lockfile calls up to date, so a reviewed translation survives a run that rewrites the file because a *sibling* key changed | +| `deepl sync push` | The `msgstr` / `` is what gets uploaded, unless it is flagged `fuzzy` or carries a review `state` | +| `deepl sync validate` | Placeholder and structure checks compare the `msgstr` against the `msgid` | +| `deepl sync pull` | For a key the export does not carry, the existing `msgstr` is kept | +| `deepl sync audit` | Terminology consistency is measured across translations | + +**An empty translation side means untranslated, not empty.** A `msgstr ""`, a `` with no ``, and an empty `` all read as "this key has no translation yet": `sync` translates the key rather than pinning the empty string, and `push` skips it (see below). This differs from the monolingual formats, where an empty value is a deliberate translation and is preserved. + +**A `#, fuzzy` msgstr is a translation the catalog will not ship.** Gettext marks an entry `fuzzy` to mean "there is a translation here, but it needs review" — `msgmerge` writes it when a source string changes and it matched an old translation, and reviewers set it by hand. `msgfmt` leaves such an entry out of the compiled `.mo`, so the application shows the **msgid**. The CLI therefore counts it as its own `needsReview` category rather than as complete, and its numbers now agree with the oracle's: on a two-key catalog with one entry flagged, `msgfmt --statistics` says `1 translated message, 1 fuzzy translation` and `deepl sync status` says `50% (0 missing, 0 outdated, 1 needs review)` where it used to say `100%`. + +What the CLI does **not** do is act on it. The msgstr is a reviewer's draft and the lockfile records that key as translated against an unchanged source, so `deepl sync` carries the value and the flag forward untouched, re-translates nothing and bills nothing — reading it as untranslated would replace a human's in-progress work with machine output, which is the one outcome worse than an overstated percentage. `deepl sync push` skips the key under the `needs_review` reason rather than uploading a draft as approved. `sync --frozen` does not treat it as drift, because a fuzzy entry is a normal, transient state that only a human review clears. Two ways out, both measured: **remove the flag** and the key counts complete again (`msgfmt`: `2 translated messages`), or **clear the msgstr** and the next `deepl sync` translates it afresh (`msgfmt`: `1 translated, 1 untranslated`, then `2 translated messages`). + +**An XLIFF review `state` says the same thing, and is read the same way.** XLIFF 1.2 records it on ``, 2.0 on ``, and the CLI counts a unit whose translation is present as `needsReview` when that attribute makes an explicit claim that the translation is not finished: + +| Version | Where | Counted as needing review | Counted as complete | +|---------|-------|---------------------------|---------------------| +| 1.2 | `` | `new`, `needs-translation`, `needs-l10n`, `needs-adaptation`, `needs-review-translation`, `needs-review-l10n`, `needs-review-adaptation` | `translated`, `signed-off`, `final` | +| 2.0 | `` | `initial` | `translated`, `reviewed`, `final` | + +An **absent** attribute counts as complete in both versions, and so does a value neither list names. That is deliberate, and it is why an existing project's coverage does not move: absence is what the CLI itself writes and what a file from a toolchain with no review workflow carries, so reading it as unfinished would report every such project as needing review. It means the CLI does **not** apply 2.0's documented `initial` default to a segment with no `state` — only an explicit claim counts. `state-qualifier` (1.2) and `subState` (2.0) are different attributes with their own vocabularies and are not read. There is no `msgfmt` equivalent to arbitrate this, so unlike the PO numbers above these are the CLI's policy rather than an oracle's. + +Everything else matches the PO case: the `` is carried forward untouched, nothing is re-translated or re-billed, `push` skips the key under `needs_review`, and `sync --frozen` does not treat it as drift. Two ways out, both measured: **change the state** to `translated` and the key counts complete again with **no API call**, or **empty the ``** and the next `deepl sync` translates it afresh. + +**A state the CLI writes over describes what the CLI wrote.** When `reconstruct` replaces a ``'s content, a `state` attribute already on that element (1.2) or on its `` (2.0) becomes `translated`. Otherwise the file would contradict itself: a source XLIFF exported with `` placeholders — a common CAT-tool shape — produced a target file whose every unit said it still needed translating about a string the CLI had just translated, and every one of those keys would then be reported as needing review forever. An element carrying no `state` gains none. 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. + +**PO entries need no blank line between them.** The blank-line separator is a convention `msgfmt -c` does not require, and a catalog written without it — after the header or between messages — is read and written entry by entry all the same. An entry ends at the first line that is not a continuation of its translation: a comment, a `msgctxt`, or the next `msgid`. Layout is preserved either way, so a catalog that arrived without the separators keeps that shape on the way out. + ## Configuration ### `.deepl-sync.yaml` @@ -153,6 +352,10 @@ Each bucket maps a format name to a set of file patterns. The format name must b | `target_path_pattern` | `string` | No | Template for target file paths. Use `{locale}` for the target locale and `{basename}` for the source filename. Required for formats where the source locale is not in the source file path (e.g., Android XML, XLIFF). | | `key_style` | `string` | No | Key format: `nested` (dot-separated keys become nested objects) or `flat` (keys preserved as-is). | +**Target path bounds.** A `target_path_pattern` may not contain `..` and may not resolve into `.git/` or `.github/`. It also may not begin with `-`, and neither may the target path it renders to: a rendered path is handed to `git` as a single argument, where a leading dash is parsed as an option rather than a filename. A dash anywhere else is fine, so the canonical Android `res/values-{locale}/strings.xml` is unaffected. A dash-leading pattern fails at config load with `ConfigError` (exit 7); a dash-leading rendered path — which `{basename}` and the default locale-substitution path can both produce from a source file whose own name begins with `-` — fails with `ValidationError` (exit 6), naming the path. + +**Glob pattern bounds.** Every glob string in the config — `include`, `exclude`, top-level `ignore`, and `context.scan_paths` — must expand to at most 1000 paths through its brace groups and be at most 4096 characters long. Both bounds are fixed and cannot be raised in the config: brace expansion is a product, so `{a,b}` repeated 20 times is only 107 characters but expands to over a million paths and exhausts the heap before any file is read. A pattern past either bound fails at config load with `ConfigError` (exit 7), naming the field. Realistic patterns are far below the cap — `{en,de,fr}/**/*.{json,yaml,yml}` expands to 9. + #### `translation` | Field | Type | Required | Default | Description | @@ -164,7 +367,7 @@ Each bucket maps a format name to a set of file patterns. The format name must b | `translation_memory_threshold` | `number` | No | `75` | Minimum match score 0–100 (requires `translation_memory`). Non-integer or out-of-range values exit 7 (ConfigError). | | `custom_instructions` | `string[]` | No | -- | Custom instructions passed to the DeepL API | | `style_id` | `string` | No | -- | Style ID for consistent translation style | -| `locale_overrides` | `object` | No | -- | Per-locale overrides for `formality`, `glossary`, `translation_memory`, `translation_memory_threshold`, `custom_instructions`, `style_id` | +| `locale_overrides` | `object` | No | -- | Per-locale overrides for `formality`, `glossary`, `translation_memory`, `translation_memory_threshold`, `custom_instructions`, `style_id`. A `model_type` here is accepted by the schema but not applied to that locale's requests — set `translation.model_type` at the top level instead | | `instruction_templates` | `object` | No | -- | Per-element-type instruction templates. Built-in defaults cover 16 element types: `button`, `a`, `h1`-`h6`, `th`, `label`, `option`, `input`, `title`, `summary`, `legend`, `caption`. User-provided templates override defaults. Only effective for locales supporting custom instructions: DE, EN, ES, FR, IT, JA, KO, ZH. See [Translation Strategies](#translation-strategies). | | `length_limits.enabled` | `boolean` | No | `false` | Enable length-aware translation instructions. Adds "Keep under N characters" per key based on source text length and locale expansion factors. Only applies to length-constrained element types (button, th, label, option, input, title) for keys sent via per-key API calls. | | `length_limits.expansion_factors` | `object` | No | built-in defaults | Per-locale expansion factors relative to English source. Built-in defaults: DE 1.3, FR 1.3, ES 1.25, JA 0.5, KO 0.7, ZH 0.5, etc. Based on industry-standard approximations (IBM, W3C). User-overridable. | @@ -177,7 +380,7 @@ Setting `translation.glossary: auto` enables automatic project glossaries. Each Set `translation.translation_memory` to a translation memory name or UUID to reuse approved translations across a sync run. Translation memories are authored and uploaded through the DeepL web UI; the CLI never creates or edits them. Names are resolved to UUIDs once via `GET /v3/translation_memories` and cached for the remainder of the invocation, so a multi-locale sync issues at most one list call per unique name. TM composes with glossary — both `glossary_id` and `translation_memory_id` are sent on the same translate call when both are configured. -Translation memories require `model_type: quality_optimized`. Set `model_type: quality_optimized` at the same scope as `translation_memory` (top-level `translation.model_type`, or the matching per-locale override). Other values are rejected at config load with `ConfigError` (exit 7), before any API call is made. Threshold propagates from YAML into each translate request (default 75, range 0–100); `translation_memory_threshold` without `translation_memory` is inert. Per-locale `locale_overrides..translation_memory` takes precedence over the top-level `translation.translation_memory`; `locale_overrides..translation_memory_threshold` falls back to the top-level threshold when unset. See [Is translation memory actually being applied?](#is-translation-memory-actually-being-applied) for verification steps. +Translation memories require `model_type: quality_optimized`. Set it at the **top level** (`translation.model_type: quality_optimized`) — that is the only scope the sync engine reads when it builds a translate request. A `model_type` inside `translation.locale_overrides.` is accepted and validated by the schema but is **not** applied to that locale's requests. Other values are rejected at config load with `ConfigError` (exit 7), before any API call is made. Threshold propagates from YAML into each translate request (default 75, range 0–100); `translation_memory_threshold` without `translation_memory` is inert. Per-locale `locale_overrides..translation_memory` takes precedence over the top-level `translation.translation_memory`; `locale_overrides..translation_memory_threshold` falls back to the top-level threshold when unset. See [Is translation memory actually being applied?](#is-translation-memory-actually-being-applied) for verification steps. #### `context` @@ -248,18 +451,32 @@ In JSON output (`--format json`), the `strategy` field provides the breakdown: - Keys with manual `context.overrides` always use per-key translation regardless of batching mode. - No-op syncs (nothing changed) complete in <200ms on typical projects. -**Rollback:** If auto-generated instructions or section context produce an undesirable translation for a specific key, edit the target file manually. The lock file preserves translations by source hash — manual edits persist across syncs as long as the source text is unchanged. Use `--force` to re-translate all keys (this overwrites manual edits). +**Rollback:** If auto-generated instructions or section context produce an undesirable translation for a specific key, edit the target file manually. The lock file preserves translations by source hash — manual edits persist across syncs as long as the source text is unchanged. Use `--force` to re-translate all keys (this overwrites manual edits, and no `.deepl.bak` survives a successful run — which is why `--force` needs a terminal to confirm on, or an explicit `--yes`). #### `validation` | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| -| `check_placeholders` | `boolean` | No | `true` | Validate placeholder preservation in translations | -| `fail_on_error` | `boolean` | No | `false` | Fail sync when validation errors are detected | -| `validate_after_sync` | `boolean` | No | `true` | Run validation after each sync | +| `check_placeholders` | `boolean` | No | `true` | Validate placeholder and ICU preservation in translations. Set `false` to write translations that fail validation | +| `fail_on_error` | `boolean` | No | `false` | Raise `ValidationError` (exit 6) instead of finishing as a partial failure when validation errors are detected | +| `validate_after_sync` | `boolean` | No | `true` | Run validation on each translation before it is written | | `fail_on_missing` | `boolean` | No | `true` | With `--frozen`, fail on new/missing translations | | `fail_on_stale` | `boolean` | No | `true` | With `--frozen`, fail on stale (source-changed) translations | +**Corrupt translations are withheld, not written.** A translation that loses a +placeholder or has its ICU structure rewritten by the engine (an +`error`-severity check) never reaches the target file. The key keeps whatever +the target file already held, the lock records it as `status: "failed"`, and the +next sync retries it — so a run with validation errors reports at least one +failed key and exits 12 (or 6 with `fail_on_error: true`). Warnings — an extra +placeholder, a missing HTML tag, an untranslated string, an unusual length +ratio — are reported but still written. + +Because a withheld key is retried, an engine that keeps returning the same +corrupt output is billed again on every run. The warning names the affected keys +and the check that rejected them; set `check_placeholders: false` to accept the +output instead, or fix the source string. + #### `sync` | Field | Type | Required | Default | Description | @@ -270,9 +487,10 @@ In JSON output (`--format json`), the `strategy` field provides the breakdown: | `max_characters` | `number` | No | -- | Cost cap: abort sync if estimated characters exceed this limit (override with `--force`) | | `backup` | `boolean` | No | `true` | Create `.deepl.bak` copies of target files before overwriting; cleaned up after successful sync | | `max_scan_files` | `number` | No | `50000` | Hard ceiling on the number of files matched by `context.scan_paths`. Prevents a misconfigured pattern from wedging the CLI on shared CI with huge source trees and slow disks. Exceeding the cap throws `ValidationError` with a suggestion to narrow the pattern. Positive integer. | +| `bak_sweep_max_age_seconds` | `number` | No | `300` (5 min) | How long a redundant `.deepl.bak` sibling is left on disk before the startup sweep removes it. Only ever applies to a backup whose target already holds the same bytes -- one whose target has diverged is kept however old it is (see [Watch Mode](#watch-mode-deepl-sync---watch)). Positive integer. | | `limits.max_entries_per_file` | `number` | No | `25000` | Per-file parser cap on extracted entry count. Files exceeding this are skipped with a warning. Hard ceiling: `100000`. Values above the ceiling fail at config load with `ConfigError` (exit 7). | | `limits.max_file_bytes` | `number` | No | `4194304` (4 MiB) | Per-file parser cap on on-disk size, checked via `fs.stat` before read. Files exceeding this are skipped with a warning. Hard ceiling: `10485760` (10 MiB). Values above the ceiling fail at config load with `ConfigError` (exit 7). | -| `limits.max_depth` | `number` | No | `32` | Per-file parser cap on associative-array nesting depth. Protects against stack-overflow on adversarial input. Currently consumed by the Laravel PHP parser. Files exceeding this are skipped with a warning. Hard ceiling: `64`. Values above the ceiling fail at config load with `ConfigError` (exit 7). | +| `limits.max_depth` | `number` | No | `32` | Per-file parser cap on nesting depth. Protects against stack-overflow on adversarial input. Consumed by the Laravel PHP and JSON parsers (the ones that walk their tree recursively); other formats are bounded by their own parser instead. Files exceeding this are skipped with a warning, and the rest of the run continues. Hard ceiling: `64`. Values above the ceiling fail at config load with `ConfigError` (exit 7). | | `limits.max_source_files` | `number` | No | `10000` | Per-bucket cap on how many source files a single `include` glob may match. Buckets exceeding this are **skipped entirely with a warning** — the assumption is that a glob which returned 10k+ files is picking up an unintended vendored tree. Narrow the `include` pattern or raise the cap. Hard ceiling: `1000000`. Values above the ceiling fail at config load with `ConfigError` (exit 7). | #### `tms` @@ -282,20 +500,40 @@ Optional integration with a translation management system (TMS) for collaborativ | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `enabled` | `boolean` | Yes | -- | Enable TMS integration | -| `server` | `string` | Yes | -- | TMS server URL (HTTPS required except for `localhost`/`127.0.0.1`) | +| `server` | `string` | Yes | -- | TMS server URL. HTTPS required, waived only for `http://localhost` and `http://127.0.0.1` -- write a local TMS as `http://localhost`, which reaches it whether it is bound to `127.0.0.1`, to `::1` or to every interface; `http://[::1]` and other loopback spellings are refused. When the credential comes from the environment, the hostname must also be approved -- see [TMS destination trust](#tms-destination-trust) | | `project_id` | `string` | Yes | -- | TMS project identifier | | `api_key` | `string` | No | -- | API key for TMS authentication (prefer `TMS_API_KEY` env var) | | `token` | `string` | No | -- | Bearer token for TMS authentication (prefer `TMS_TOKEN` env var) | -| `auto_push` | `boolean` | No | `false` | Automatically push after sync | -| `auto_pull` | `boolean` | No | `false` | Automatically pull before sync | -| `require_review` | `string[]` | No | -- | Locales that require human review before pull | | `timeout_ms` | `number` | No | `30000` | Per-request timeout in milliseconds for TMS HTTP calls (positive integer). Aborts the request via `AbortController` when exceeded. | | `push_concurrency` | `number` | No | `10` | Maximum number of in-flight `PUT /keys/{keyPath}` requests during `deepl sync push`. Positive integer. Applied per (file, locale) batch of entries; aborts remaining pushes on first failure. | +**Removed fields.** `auto_push`, `auto_pull` and `require_review` were accepted by the schema through `1.x`, and no code ever read any of them -- a `require_review` a user configured expecting a human gate before pull got no gate and no warning. All three now fail config load with a `ConfigError` (exit 7) naming them, rather than being silently tolerated. Push and pull after or before a sync by running `deepl sync push` / `deepl sync pull` explicitly, which also keeps the credential and destination decision on a command you typed. A review gate cannot be enforced from the CLI side because the export contract below carries no per-entry review flag; use [`deepl sync pull --dry-run`](#deepl-sync-pull) to preview a pull and review the result before committing it. + +##### TMS destination trust + +`server` is chosen by this file, which lives in the checkout, while `TMS_API_KEY` / `TMS_TOKEN` come from the operator's environment. A checkout you do not control could therefore name the host that receives your credential and every translated string. Before an **environment-supplied** credential is attached to a request, the destination hostname must be approved in the operator's own configuration: + +```bash +# Approve a destination up front (comma-separate several hosts) +deepl config set tms.allowedServers tms.example.com +``` + +- Listed in `tms.allowedServers` → the run proceeds silently. +- Not listed, interactive terminal → the CLI names the host and what would be sent, and asks once. Answering yes records the hostname in **user** config (`~/.config/deepl-cli/config.json`), never in the repository, so the answer survives a fresh clone of the same project and does not travel with the repo. +- Not listed, no terminal (`--no-input`, CI) or declined → exit 7 (`ConfigError`) naming the host and the exact `deepl config set` command. Nothing is sent. + +Matching is against a parsed URL hostname: exact and case-insensitive, ignoring scheme, port and path. A listed `example.com` does **not** approve `tms.example.com`, and there are no wildcards. `localhost` and `127.0.0.1` are **not** exempt — a co-tenant process listening on loopback is still an exfiltration sink, even though `buildUrl` waives the HTTPS requirement for them. + +The gate does not apply to a credential inlined as `api_key` / `token` in this file: it belongs to the same file that chose the destination, so nothing of the operator's leaks. Inlining a credential is still discouraged (it commits a secret), and the CLI warns about it separately. + +`deepl sync push` and `deepl sync pull` print the resolved destination origin on success in both text and JSON output, so a redirected destination is visible in logs even for an already-approved host. + ##### TMS reliability: timeouts and retries Each TMS HTTP request is bounded by `tms.timeout_ms` (default 30000 ms) using an `AbortController`. If the configured timeout elapses before the server responds, the client raises a `TmsTimeoutError` rather than hanging indefinitely. +A TMS that never answers at all is reported the same way: a refused connection, an unresolvable hostname or any other failure of the request itself exits **5** (`NetworkError`) with the method, the URL and the underlying code (`TMS request failed: PUT https://tms.example.com/api/projects/p/keys/greeting: ECONNREFUSED`). Which of those failures is retried is unchanged by that, and is listed below. + `429 Too Many Requests` and `503 Service Unavailable` responses, along with transient network errors (`ECONNRESET`, `ETIMEDOUT`, `ECONNREFUSED`, `EAI_AGAIN`) and timeouts, are retried up to 3 attempts total with jittered exponential backoff starting at ~500 ms, doubling each attempt, and capped at ~10 s (±25% jitter). All other `4xx` responses (including `401`/`403` auth failures) are not retried. When a non-2xx response is finally surfaced to the caller, up to 1 KB of the response body is appended to the error message so operators can diagnose the failure without reproducing it under `curl`. `deepl sync push` issues per-key `PUT` requests concurrently, bounded by `tms.push_concurrency` (default 10). A 5000-key × 10-locale project at ~100 ms round-trip completes in minutes instead of hours. Pushes abort on first failure — a partial push is confusing and operators re-run after fixing the underlying cause — so the overall semantic (fail-fast, caller retries) is unchanged from the previous serial behavior. @@ -328,6 +566,8 @@ The lockfile is auto-generated and should be committed to version control. It st **Recovery.** If sync encounters a lockfile with an unsupported `version` or invalid JSON, it copies the existing file to `.deepl-sync.lock.bak--` (tag is `corrupt`, `v-unknown`, or `v`) before resetting in-memory state and continuing with a full re-sync. The backup path is logged at WARN level so you can restore it from the working tree if needed. +**Malformed structure.** A lockfile that parses as JSON at the supported version is still checked member by member before it is used, because it is read from the repository and every level of it can be wrong. A per-file map, an entry, an entry's `translations` container or an individual translation that is not an object is **dropped**, and the keys behind it are translated again on that run. Dropping is per member rather than per file: discarding a whole lockfile over one bad entry would re-translate — and re-bill — the entire project. The count is reported at WARN level with a `-malformed` backup, so the original is recoverable. `entries` that is not a map at all (an array, for instance) carries nothing to salvage and takes the full re-sync path above, tagged `-entries-not-a-map`. The `stats` block is never read for its own sake — it is derived from `entries` and recomputed on write — so it is recomputed on read rather than trusted, and a missing or malformed `stats` is repaired silently. + Each translation entry in the lockfile records: | Field | Description | @@ -337,7 +577,27 @@ Each translation entry in the lockfile records: | `status` | `translated`, `failed`, or `pending` | | `character_count` | Characters billed by the DeepL API for this translation | | `context_sent` | `true` when source code context was included in the API request | -| `review_status` | `machine_translated` or `human_reviewed` (set by `--flag-for-review`) | +| `review_status` | `machine_translated` (set by `--flag-for-review`) or `human_reviewed`. The CLI only ever writes `machine_translated`; `human_reviewed` is honoured when a person or another tool sets it, and is never inferred from a TMS pull. Absent means unknown | + +**Formatting.** Containers are written one key per line, but each translation entry is written on a **single line**: + +```json +"translations": { + "de": {"hash": "185f8db32271", "review_status": "human_reviewed", "status": "translated", "translated_at": "2026-04-20T08:12:03Z"} +} +``` + +This is a merge-safety property, not a style choice. A translation is only meaningful whole — its hash, timestamp and review status all describe one act of translating. With one field per line, `git merge` treats those fields as independently mergeable units: a branch that changed only `review_status` and a branch that changed only `translated_at` merge **with no conflict at all** into an entry that existed on neither branch, labelling machine output `human_reviewed` while carrying a timestamp from the other branch. One line per entry makes the smallest region git can produce a whole entry, so such an overlap conflicts and reaches [`deepl sync resolve`](#deepl-sync-resolve) instead of being silently combined. Keys are sorted, so the diff for a changed translation is a single line. + +The `stats` block is written on one line for a neighbouring reason: + +```json +"stats": {"last_sync": "2026-04-20T08:12:03Z", "total_keys": 412, "total_translations": 1236} +``` + +`stats` sits two lines below `generated_at`, and every write changes both, so git joins the two into a single conflict region. Expanded over five lines, that region opens inside `stats` and closes outside it — not a member list any parser can read, so `sync resolve` could not merge it and fell back to picking a side by byte length, warning about possible data loss on **every** ordinary lockfile merge. On one line the region stays a member list the resolver merges normally, and a `length-heuristic` warning again means what it says. + +Upgrading from an earlier release reformats every existing lockfile on the next write, which produces one large diff. It is a formatting change only — no entry's content changes. ### Multiple Buckets @@ -382,7 +642,7 @@ deepl sync [OPTIONS] | `--dry-run` | Preview changes without translating | | `--frozen` | Fail (exit 10) if any translations are missing or outdated; for CI/CD | | `--ci` | Alias for `--frozen` | -| `--force` | Re-translate all strings, ignoring the lockfile. **Also bypasses the `sync.max_characters` cost cap** — a forced run can re-bill every key. Preview cost with `--dry-run` first. Billing safety guards: `--watch --force` is rejected at startup with `ValidationError` (exit 6); interactive mode prompts for confirmation (add `--yes`/`-y` to skip in scripts); in CI (`CI=true`), `--force` requires explicit `--yes` or exits 6. | +| `--force` | Re-translate all strings, ignoring the lockfile. **Also bypasses the `sync.max_characters` cost cap** — a forced run can re-bill every key. Preview cost with `--dry-run` first. Billing safety guards: `--watch --force` is rejected at startup with `ValidationError` (exit 6); interactive mode prompts for confirmation (add `--yes`/`-y` to skip in scripts); **without a terminal to prompt on — CI, a git hook, cron, a container entrypoint, `< /dev/null`, `--no-input` — `--force` is refused with exit 6, so `--yes` is the only way to run it unattended.** | | `--locale ` | Sync only specific target locales (comma-separated) | | `--concurrency ` | Max parallel locale translations (default: 5) | | `--batch` | Force plain batch mode (fastest, no context or instructions). | @@ -396,6 +656,8 @@ deepl sync [OPTIONS] | `--flag-for-review` | Mark translations as `machine_translated` in lock file for human review | | `--watch` | Watch source files and auto-sync on changes | | `--debounce ` | Debounce delay for watch mode (default: 500ms) | +| `-y`, `--yes` | Confirm `--force` without a prompt. Required to run `--force` unattended (no terminal, `--no-input`, CI) | +| `--break-lock` | Take the sync lock even when `.deepl-sync.lock.pidfile` names a process that looks alive. Unsafe if that sync really is running -- see [Concurrent sync](#concurrent-sync) | | `--sync-config ` | Path to `.deepl-sync.yaml` (default: auto-detect) | **Examples:** @@ -444,7 +706,16 @@ Sync complete: 2 new, 1 updated, 7 current (100 chars, ~$0.00) (Pro tier estimat Each locale shows `✓` when all translations succeeded, or `✗` when some failed. The format is `locale: translated/attempted`. -In `--dry-run` mode, the output includes estimated characters and cost (computed from source string lengths × target locales, no API calls): +When some translations failed, the summary line names how many. The key counts describe the **diff** — how many keys were new, updated or current — not how many were successfully translated, so without this the line read as though every diffed key had landed: + +``` + ✗ de: 50/200 keys (locales/en.json) +Sync complete: 200 new (150 translations failed) +``` + +The count totals every locale, so a run failing 4 keys in `de` and 3 in `fr` reports `7 translations failed`. Exit code is 12 (partial failure). + +In `--dry-run` mode, the output includes estimated characters and cost (computed from source string lengths × the target locales the run would actually translate, no API calls): ``` [dry-run] No translations performed. @@ -452,11 +723,22 @@ Sync complete: 12 new, 3 updated across 5 languages This sync: ~4,500 chars, ~$0.11 (Pro tier estimate) ``` +The estimate covers repair work as well as new work. A key the lock file calls translated that its target file no longer holds is counted `current` in the summary — it is current as far as the lock file goes — so the run says why the estimate is not zero: + +``` +[dry-run] No translations performed. +Sync complete: 2 current across 1 language +1 key is recorded as translated in the lock file but could not be confirmed in +the target file, so a real run would translate it again. Run `deepl sync status` +to see which keys, in which file, and why. +This sync: ~12 chars, <$0.01 (Pro tier estimate) +``` + Cost estimates use the DeepL Pro rate ($25 per 1 million characters). If you are on the Free tier or a different plan, your actual cost will differ. Check your account tier at [deepl.com](https://deepl.com) to determine the applicable rate. With `--format json`, the result includes `estimatedCharacters`, `targetLocaleCount`, `estimatedCost`, and `rateAssumption: "pro"` (indicating the estimate is based on Pro-tier pricing). A `perLocale` array provides per-file per-locale breakdowns. -**stdout/stderr split (stable contract).** The final success JSON payload is written to **stdout**, so `deepl sync --format json > out.json` captures the result in a parseable file. Progress events (both `key-translated` and `locale-complete`) are streamed to **stderr** as JSON lines during the sync, enabling programmatic progress monitoring without polluting the consumer contract on stdout. On failure, `--format json` emits the nested error envelope `{"ok":false,"error":{"code":"...","message":"...","suggestion":"..."},"exitCode":N}` to **stderr** and exits non-zero; the `error.code` field matches the error class name (e.g. `ConfigError`). +**stdout/stderr split (stable contract).** The final JSON payload — the success result, or the error envelope when the command fails — is written to **stdout**, so `deepl sync --format json > out.json` captures a parseable file in both cases. Progress events (both `key-translated` and `locale-complete`) are streamed to **stderr** as JSON lines during the sync, enabling programmatic progress monitoring without polluting the consumer contract on stdout. On failure, `--format json` emits the nested error envelope `{"ok":false,"error":{"code":"...","message":"...","suggestion":"..."},"exitCode":N}` to **stdout** and exits non-zero; the `error.code` field matches the error class name (e.g. `ConfigError`). Warnings — the CLI's own and the Node runtime's — go to stderr, so they can never break a consumer parsing stdout. ### `deepl sync init` @@ -473,7 +755,7 @@ deepl sync init [OPTIONS] | `--source-locale ` | Source locale code | | `--target-locales ` | Target locales (comma-separated) | | `--file-format ` | File format (choices: `json`, `yaml`, `toml`, `po`, `android_xml`, `ios_strings`, `xcstrings`, `arb`, `xliff`, `properties`, `laravel_php`) | -| `--path ` | Source file path or glob pattern | +| `--path ` | Source file path or glob pattern | | `--sync-config ` | Path to `.deepl-sync.yaml` (default: auto-detect) | `--source-lang` and `--target-langs` were accepted as deprecated aliases during `1.x` and were removed in `2.0.0`; use `--source-locale` and `--target-locales`. The `--locale` filter on `sync push` / `pull` / `status` / `export` is unchanged. `deepl translate --target-lang` is unchanged — it operates on strings and stays aligned with the DeepL API's wire name. @@ -555,7 +837,7 @@ Source: en (142 keys) ja [##################..] 91% (12 missing, 0 outdated) ``` -Each row shows: locale code, a 20-character ASCII progress bar (`#` = translated, `.` = missing/outdated), integer coverage percentage, and a parenthetical with missing and outdated key counts. +Each row shows: locale code, a 20-character ASCII progress bar (`#` = complete, `.` = anything not complete), integer coverage percentage, and a parenthetical with the missing and outdated counts -- plus `N unwritten` and `N needs review` when either is non-zero. **JSON output:** @@ -569,19 +851,22 @@ deepl sync status --format json "totalKeys": 142, "skippedKeys": 1, "locales": [ - { "locale": "de", "complete": 140, "missing": 2, "outdated": 0, "coverage": 98 }, - { "locale": "fr", "complete": 142, "missing": 0, "outdated": 0, "coverage": 100 }, - { "locale": "es", "complete": 138, "missing": 4, "outdated": 0, "coverage": 97 }, - { "locale": "ja", "complete": 130, "missing": 12, "outdated": 0, "coverage": 91 } - ] + { "locale": "de", "complete": 140, "missing": 2, "outdated": 0, "unwritten": 0, "needsReview": 0, "coverage": 98 }, + { "locale": "fr", "complete": 142, "missing": 0, "outdated": 0, "unwritten": 0, "needsReview": 0, "coverage": 100 }, + { "locale": "es", "complete": 138, "missing": 4, "outdated": 0, "unwritten": 0, "needsReview": 0, "coverage": 97 }, + { "locale": "ja", "complete": 129, "missing": 12, "outdated": 0, "unwritten": 0, "needsReview": 1, "coverage": 90 } + ], + "unwrittenByLocale": [] } ``` -**Per-locale fields:** `complete` — keys with a current translation; `missing` — keys with no translation entry; `outdated` — keys whose source content has changed since the last sync (translation exists but is stale); `coverage` — integer 0–100 computed as `complete / (complete + missing + outdated) * 100`. +**Per-locale fields:** `complete` — keys with a current translation; `missing` — keys with no translation entry, or one recorded as a failed attempt; `outdated` — keys whose source content has changed since the last sync (translation exists but is stale); `unwritten` — keys the lock file records as translated that the target file does not hold (see [A string added after the first sync](#a-string-added-after-the-first-sync)); `needsReview` — keys whose translation is present but marked as needing review, a gettext `#, fuzzy` msgstr or an XLIFF review `state` (see [Bilingual formats: PO and XLIFF](#bilingual-formats-po-and-xliff)); `coverage` — integer 0–100 computed as `complete / (complete + missing + outdated + unwritten + needsReview) * 100`. Neither `unwritten` nor `needsReview` is ever counted as `complete`. + +**`unwrittenByLocale`** is a top-level array carrying the keys behind each locale's `unwritten` count: `{ "locale", "file", "keys": [...] }`, plus an `unusable` field holding the read or parse error when the target file itself could not be read. **`skippedKeys`** counts entries the parser tagged as untranslatable and excluded from the translation batch — currently only Laravel pipe-pluralization values (`|{n}`, `|[n,m]`, `|[n,*]`). Included in `totalKeys`; round-trip byte-verbatim via reconstruct. -**JSON output contract.** The field set above is stable within a major version. `coverage` is an integer 0-100. The success payload is written to **stdout** so `deepl sync status --format json > status.json` captures it in a parseable file; diagnostic logs remain on stderr. On failure, `--format json` emits the nested error envelope `{"ok":false,"error":{"code":"...","message":"...","suggestion":"..."},"exitCode":N}` to **stderr** and exits non-zero; `error.code` matches the error class name (e.g. `ConfigError`). The same stdout/stderr split applies to `deepl sync validate --format json` and `deepl sync audit --format json`. +**JSON output contract.** The field set above is stable within a major version. `coverage` is an integer 0-100. The success payload is written to **stdout** so `deepl sync status --format json > status.json` captures it in a parseable file; diagnostic logs remain on stderr. On failure, `--format json` emits the nested error envelope `{"ok":false,"error":{"code":"...","message":"...","suggestion":"..."},"exitCode":N}` to **stdout** as well — the envelope is the command's result in the failure case, and the non-zero exit code is the failure signal — so a consumer parses one stream in both cases. `error.code` matches the error class name (e.g. `ConfigError`). The same stdout/stderr split applies to `deepl sync validate --format json` and `deepl sync audit --format json`. **Casing.** CLI JSON output uses `camelCase`. The on-disk lockfile and config use `snake_case`. This split is deliberate — JSON is a consumer contract, files are authored configuration. @@ -604,29 +889,29 @@ deepl sync validate [OPTIONS] **Example output:** ``` -Validation Results: - - de: - ✓ 138/140 strings valid - ✗ 2 issues found: - - messages.welcome: placeholder {name} missing in translation - - errors.count: format specifier %d replaced with %s +$ deepl sync validate +Checked 4 translations - fr: - ✓ 142/142 strings valid + ERROR de/greeting: Missing placeholders in translation: {name} + WARN de/bye: Translation is identical to source text - 2 issues found across 1 locale. +1 error(s), 1 warning(s) ``` +One line per issue, `ERROR` or `WARN` followed by `/` and the check's message. A run with no issues prints the header and `All translations passed validation.` Any error-severity issue exits **8** (`CheckFailed`); warnings alone still exit 0. + +**JSON output** (`--format json`) carries `totalChecked`, `passed`, `warnings`, `errors`, and an `issues` array whose entries hold `key`, `source`, `translation`, `severity`, `locale`, `file`, and a nested `issues` array of `{ check, severity, message, details }`. + **Checks performed:** - Named placeholders present in translation (e.g., `{name}`, `{{count}}`, `%{user}`) - Format specifiers match source (e.g., `%d`, `%s`, `%@`) -- HTML/XML tags balanced and matching +- Every HTML/XML tag in the source is still present in the translation (warning; tag nesting and balance are not checked, and extra tags in the translation are not reported) - ICU message syntax valid (plurals, selects) - No untranslated strings copied verbatim from source - Length ratio warnings (translation >150% of source length) -- Key count matches between source and target files + +Key-level completeness is **not** a validate check: a target file missing keys the lock file claims is reported by `deepl sync status` as `unwritten` (see [A string added after the first sync](#a-string-added-after-the-first-sync)), not here. Validate compares source/translation pairs it can actually see. ### `deepl sync export` @@ -643,7 +928,7 @@ deepl sync export [OPTIONS] | `--locale ` | Export for specific locales only | | `--output ` | Write to file instead of stdout | | `--overwrite` | Overwrite `--output` if it already exists (default: refuse to clobber) | -| `--format ` | Output format: `text` (default), `json`. Success output is always XLIFF 1.2; `json` affects the **error** envelope on stderr so script consumers can parse failure shape uniformly with other sync subcommands | +| `--format ` | Output format: `text` (default), `json`. Success output is always XLIFF 1.2; `json` affects the **error** envelope on stdout so script consumers can parse failure shape uniformly with other sync subcommands | | `--sync-config ` | Path to `.deepl-sync.yaml` (default: auto-detect) | Without `--overwrite`, `deepl sync export --output` refuses to write over an existing file and exits 6 (ValidationError). Pass `--overwrite` to re-export: @@ -666,6 +951,7 @@ deepl sync resolve [OPTIONS] |------|-------------| | `--format ` | Output format: `text` (default), `json` | | `--dry-run` | Print the per-entry decision report without writing the lockfile | +| `--break-lock` | Take the sync lock even when `.deepl-sync.lock.pidfile` names a process that looks alive. Unsafe if that sync really is running -- see [Concurrent sync](#concurrent-sync) | | `--sync-config ` | Path to `.deepl-sync.yaml` (default: auto-detect) | **Output.** The resolver now emits a per-entry report: one line per decision, plus a summary. Each line names the file, key, chosen side (`kept ours` / `kept theirs`), and the reason (typically the winning `translated_at` timestamp). Example: @@ -677,10 +963,27 @@ WARN locales/de/app.json: — parse-error fallback used, JSON. Resolved 3 conflicts (1 theirs, 1 ours, 1 length-heuristic). Run "deepl sync" to fill any gaps. ``` +**The tie-break arbitrates a whole translation, never its fields.** A conflicting pair is arbitrated as one unit whenever *either* side looks like a translation, so the resolver cannot emit a translation whose fields come from both sides — the failure the one-line format described under [`.deepl-sync.lock`](#deepl-synclock) exists to prevent. That matters because `translated_at` is not guaranteed to be there: nothing validates it on read, and `human_reviewed` is only ever set by hand, so a hand-edited entry can be missing it. Each case is decided explicitly and named in the report: + +| Both sides | Kept | Reported as | +|------------|------|-------------| +| Different `translated_at` | The later one | `kept ours/theirs: newer translated_at ` | +| The same `translated_at` | Ours | `kept ours: same translated_at on both sides` | +| Only one has `translated_at` | The side that has it | `kept ours/theirs: had no translated_at` | +| Neither has one | Ours | `kept ours: neither side had translated_at` | + +A `translated_at` that is not a string counts as absent rather than being compared against an ISO timestamp. + +**Region terminators.** A conflict region that sits inside an object is a sequence of members, and unless it is the last one it ends with a comma joining it to the member that follows. The resolver takes that comma off before parsing the region and puts it back afterwards — without this, every region but the last failed to parse and fell to the length heuristic below, so the documented `translated_at` tie-break never ran. When the two sides *disagree* on whether the region ends a member list — one side deleted what the other modified — no terminator can be correct for both, so the region falls to the heuristic rather than risk emitting invalid JSON. + +**Canonical output.** Resolving rebuilds each merged region, so the resolved lockfile is written back in the canonical format described under [`.deepl-sync.lock`](#deepl-synclock) rather than with the resolver's own indentation. Committing a resolved lockfile therefore cannot leave translations expanded across lines for the next merge to recombine. + **Fallback behavior.** When `JSON.parse` fails on a conflict fragment (e.g., conflict markers landed mid-entry and split the JSON), the resolver falls back to a length-heuristic: the longer side wins. This is now **loud** — it logs a `WARN` line naming the file + conflict region with the truncated parse error, and the decision is tagged `length-heuristic` in the report. Earlier releases ran this heuristic silently, making auto-resolve a data-loss risk the user could not audit without a diff against git history. Inspect any `length-heuristic` entries and consider resolving them by hand. **Dry-run.** `--dry-run` runs the full decision pipeline and prints the per-entry report without touching the lockfile. Use it to preview what `sync resolve` would do, especially when the report includes `length-heuristic` fallback warnings. +**Concurrency.** `sync resolve` takes the process lock described under [Concurrent sync](#concurrent-sync), `--dry-run` included, and exits 7 while another sync holds it. Without the lock a resolve landing mid-sync was erased: the running sync had already read the pre-merge lockfile and overwrote the merged one when it finished, both steps at exit 0. The resolved lockfile is written by rename, so an interrupted resolve leaves the previous lockfile rather than a truncated one — a truncated lockfile reads as corrupt and costs a full re-translation. + After resolving, run `deepl sync` to fill any translation gaps. ### Watch Mode (`deepl sync --watch`) @@ -702,7 +1005,18 @@ deepl sync --watch --dry-run **Event coalescing.** Only one sync runs at a time. If more file-change events arrive while a sync is in flight, they are coalesced into a **single** follow-up run that starts after the current sync completes — no matter how many bursts of edits fired in between. Earlier releases silently dropped these in-flight events, which could leave the final edit of a burst unsynced until the user triggered another change manually. -**Stale `.deepl.bak` sweep.** On startup, the watcher sweeps for `.deepl.bak` siblings older than 5 minutes (files with any other suffix, including plain `.bak`, are never touched). A stale backup whose target file exists but is empty is auto-restored from the backup before the backup is removed; in every other case — including a missing target, which is never recreated — the stale backup is removed outright. This recovers cleanly from a previous watcher that was killed mid-translation without leaving residue for the user to manually clean up. +**Stale `.deepl.bak` sweep.** On startup, the watcher sweeps for `.deepl.bak` siblings older than 5 minutes (files with any other suffix, including plain `.bak`, are never touched). The sweep only ever deletes: no target file is ever created or overwritten from a backup. Target writes go through an atomic write-then-rename, so a watcher killed mid-translation cannot leave a truncated or zero-length target that would need restoring. The sweep is scoped to the directories implied by each bucket's `include` globs; a glob that begins with a wildcard (`**/en.json`, `*.json`) has no literal directory prefix to scope to and is skipped, so a bucket configured that way may accumulate `.deepl.bak` files. Run with `--verbose` to see when a glob is skipped for this reason. + +**Age is not the only condition.** A stale backup is deleted only when the file beside it already holds the same bytes. A run that reaches its end unlinks its own backups, so a backup still on disk is from a run that did not — and there it holds the only surviving copy of whatever that run had already overwritten. One whose target has diverged is kept however old it is, and reported once per sweep: + +``` +Keeping 1 leftover backup file whose content is not in the file beside it: +locales/de.json.deepl.bak. It is from a run that did not finish, so it may +hold the only copy of translations that run overwrote. Compare it with the +file it backs up before removing it; it is not swept while the two differ. +``` + +Retention is bounded at one file per target, because the backup's name is derived from the target's. `bak_sweep_max_age_seconds` still sets how long a redundant backup is left on disk; it no longer sets a deadline for recovering a crashed run. **Scope.** Watched paths are the `buckets.*.include` globs from `.deepl-sync.yaml`, plus `.deepl-sync.yaml` itself. When `.deepl-sync.yaml` changes, the config is reloaded from disk before the next sync cycle runs — YAML values like bucket definitions, `formality`, `glossary`, and `model_type` are picked up without restarting the watcher. Sending `SIGHUP` (`kill -HUP `) also force-reloads the config immediately, without waiting for a file-change event. Watch mode does not cross-talk with the separate `deepl watch` command (which is for translating individual plain-text files). @@ -759,6 +1073,10 @@ deepl sync push [OPTIONS] | `--format ` | Output format: `text` (default), `json` | | `--sync-config ` | Path to `.deepl-sync.yaml` (default: auto-detect) | +**A key with no translation yet is not pushed.** Push reads each target file and uploads the translation it holds. A bilingual target file (see [Bilingual formats: PO and XLIFF](#bilingual-formats-po-and-xliff)) lists every key the source has, translated or not, so pushing such a key would upload its source text as the locale's translation and make the TMS the authority for it -- a later `pull` would then write English into the target file. Those keys are skipped and reported under the `untranslated` reason, with the pushed count reflecting only what was actually sent. A PO entry flagged `#, fuzzy`, and an XLIFF unit whose review `state` says the translation is not finished, are skipped too, under `needs_review`: they have a translation, but one their own toolchain will not ship (see [Bilingual formats](#bilingual-formats-po-and-xliff)). For a monolingual format nothing is skipped on this account: every key the target file lists has a value. + +Two further reasons can appear in the `(N skipped: …)` summary for either command: `no_matches`, when a bucket's source file yielded no keys for that locale, and `pipe_pluralization`, for Laravel pipe-pluralization values (`|{n}`, `|[n,m]`, `|[n,*]`), which are never sent to a TMS -- one exported string cannot fill their branches, the same reason `plural_entry` exists for gettext and Android plurals. + ### `deepl sync pull` Pull approved translations from a TMS back into local files. @@ -774,9 +1092,23 @@ deepl sync pull [OPTIONS] | `--locale ` | Pull specific locales only | | `--format ` | Output format: `text` (default), `json` | | `--sync-config ` | Path to `.deepl-sync.yaml` (default: auto-detect) | +| `--dry-run` | Preview what the pull would change without writing any file | +| `--break-lock` | Take the sync lock even when `.deepl-sync.lock.pidfile` names a process that looks alive. Unsafe if that sync really is running -- see [Concurrent sync](#concurrent-sync) | Each target locale's approved dictionary is fetched exactly once per `sync pull` run (one GET per locale from the TMS export endpoint), then applied to every matching source file locally. Projects with many source files per bucket do not multiply wire bytes by the source-file count. +**The TMS wins, and pull says how often it did.** For a key the export carries, the TMS value replaces whatever the target file holds -- there is no timestamp on either side to compare, so "which is newer" is not a question the CLI can answer. That is the intended direction of `pull` (the export is the reviewed copy), but it means a hand edit made locally since the last push is overwritten. Pull therefore reports the count of existing local translations it replaced, and `--verbose` names each key and file. Run `deepl sync pull --dry-run` first to see that count -- and, with `--verbose`, the exact keys -- before anything is written. `--dry-run` writes neither target files nor `.deepl-sync.lock`; the JSON output carries `replaced` and `dryRun` alongside `pulled`. + +**A key with no translation anywhere is left out.** When the export omits a key and the target file has no value for it either, the key is omitted from the reconstructed target rather than filled in with the source string. Writing the source text would put the source language in the target file and record it in the lockfile as translated, which no later run revisits. An empty string counts as a translation and is preserved. + +**Pull does not claim human review.** The export endpoint returns `{ "key": "value" }` with no per-entry review flag, so pulled entries are recorded with `status: translated` and **no** `review_status`, meaning "unknown". Earlier releases stamped `review_status: human_reviewed` on every pulled key, asserting a review the CLI had not verified and the response did not describe. + +**A plural entry is left as it stands.** The export carries one string per key, which cannot fill a plural entry's forms (gettext `msgstr[N]`, an Android `` element). Such a key is skipped under the `plural_entry` reason, is not counted as pulled or replaced, and no lockfile entry is recorded for it — see [A plural entry carried forward](#a-plural-entry-carried-forward). + +**A target file another sync configuration also writes is left untouched.** A pull rebuilds the target from this configuration's own source keys, so it would delete any key the file holds that this configuration does not account for. When a `.deepl-sync.lock` above that file — belonging to another configuration — records those keys for the same locale, the locale is skipped under the `shared_target` reason with a warning naming that lockfile, nothing is written and nothing is recorded. See [Two configurations writing one file](#two-configurations-writing-one-file). + +**A target file that cannot be read is left untouched.** Pull reads the existing target file so it can keep translations the export does not carry. If that file cannot be opened, cannot be parsed, or cannot be keyed unambiguously, the locale is skipped with a warning naming the file and the reason, and the file is not written — under the `key_collision` reason when keys collide (see [Key separators and colliding keys](#key-separators-and-colliding-keys)), which has its own remediation, and `unusable_target` otherwise. Pull deliberately does not fall back to reconstructing it from the source file in either case: that would replace the whole file with just the keys the export happened to carry. Only a file that is genuinely not there yet takes that fallback. See [A target file that cannot be read](#a-target-file-that-cannot-be-read). + **Key-count limit:** The pull response is capped at **50,000 keys** (`MAX_PULL_KEY_COUNT`). Responses exceeding this limit are rejected with a `ValidationError` before any data is written. If your TMS project exceeds this threshold, partition the export by locale or paginate the pull on the TMS side before invoking `deepl sync pull`. **Note:** `push` and `pull` require a TMS that implements the REST contract documented above. They are optional -- `deepl sync` works entirely locally with just the DeepL Translation API. @@ -818,7 +1150,7 @@ jobs: DEEPL_API_KEY: ${{ secrets.DEEPL_API_KEY }} ``` -The `--frozen` flag causes the sync engine to exit with code 10 if any translations are missing or outdated, without making any API calls. This is ideal for pull request checks. +The `--frozen` flag causes the sync engine to exit with code 10 if any translations are missing or outdated. It performs the drift check entirely from `.deepl-sync.lock` and the target files and issues no API requests, but it still requires a configured API key and exits 2 (`AuthError`) without one — so a fork-originated pull request, where `secrets` are empty, needs either a key available to forks or a job condition that skips the check. This is ideal for same-repository pull request checks. ### GitHub Actions (auto-sync) @@ -943,7 +1275,7 @@ Run `deepl sync validate` to detect placeholder issues. Consider adding a glossa The sync engine automatically preserves these placeholder formats during translation: - Simple variables: `{name}`, `{{count}}`, `${userId}` - Printf-style: `%s`, `%d`, `%1$s`, `%2$d` -- ICU MessageFormat: `{count, plural, one {# item} other {# items}}` — structural keywords (`plural`, `select`, `selectordinal`, `one`, `other`, `few`, `many`, `zero`, `two`) and variable names are preserved; only leaf text is translated. Nested ICU (e.g., select inside plural) is supported. +- ICU MessageFormat: `{count, plural, one {# item} other {# items}}` — structural keywords (`plural`, `select`, `selectordinal`, `one`, `other`, `few`, `many`, `zero`, `two`) and variable names are preserved; only leaf text is translated. Nested ICU (e.g., select inside plural) is supported, as are several blocks in one message (`You have {n, plural, ...} and {m, plural, ...} waiting.`) and prose between them. A message containing a block that will not parse is left untouched rather than partly protected. ### Rate limiting (HTTP 429) @@ -965,6 +1297,9 @@ Each target locale translates independently. If the API returns a transient erro |------|---------| | 0 | Success -- all translations up to date | | 1 | General error -- unclassified failure (inspect stderr) | +| 2 | Authentication error -- API key missing or rejected; aborts the whole run, since every locale shares the credential | +| 3 | Rate limit exceeded -- retryable | +| 5 | Network error -- DeepL API or TMS unreachable (connection refused, DNS failure, timeout); retryable | | 6 | Invalid input -- bad arguments or unsupported format | | 7 | Config error -- invalid or missing `.deepl-sync.yaml` | | 8 | Validation failed -- `deepl sync validate` found issues | @@ -977,6 +1312,6 @@ See [API.md Exit Codes appendix](API.md#exit-codes) for detailed per-code descri ## Further Reading - [API Reference](./API.md#sync) -- complete command and flag reference -- [Example: Basic Sync](../examples/30-sync-basic.sh) -- walkthrough of a typical sync workflow -- [Example: CI/CD Sync](../examples/31-sync-ci.sh) -- using sync in automated pipelines +- [Example: Basic Sync](../examples/22-sync-basic.sh) -- walkthrough of a typical sync workflow +- [Example: CI/CD Sync](../examples/23-sync-ci.sh) -- using sync in automated pipelines - [CHANGELOG](../CHANGELOG.md) -- release history diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index d6146efa..53381849 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -13,6 +13,7 @@ Common issues and solutions when using the DeepL CLI. - [Voice API Errors (Exit Code 9)](#voice-api-errors-exit-code-9) - [Write API Issues](#write-api-issues) - [Configuration Errors (Exit Code 7)](#configuration-errors-exit-code-7) + - ["Another sync is already running" (Exit Code 7)](#another-sync-is-already-running-exit-code-7) - [Input Validation Errors (Exit Code 6)](#input-validation-errors-exit-code-6) - [Cache Issues](#cache-issues) - [Document Translation Issues](#document-translation-issues) @@ -156,7 +157,11 @@ Common issues and solutions when using the DeepL CLI. 2. Verify DeepL API is reachable: ```bash - curl -s https://api-free.deepl.com/v2/languages -H "Authorization: DeepL-Auth-Key YOUR_KEY" + # Free keys (those ending in :fx) + curl -s "https://api-free.deepl.com/v3/languages?resource=translate_text" -H "Authorization: DeepL-Auth-Key YOUR_KEY" + + # Pro keys — the free host answers a Pro key with 403, which is a reachable server + curl -s "https://api.deepl.com/v3/languages?resource=translate_text" -H "Authorization: DeepL-Auth-Key YOUR_KEY" ``` 3. If behind a corporate proxy, configure it via environment variables: @@ -198,6 +203,8 @@ Common issues and solutions when using the DeepL CLI. - **Exit 0** — Text is clean, no improvements suggested. - **Exit 8** — Improvements were found and suggested. +The same code is returned by `deepl correct --check` and by `deepl sync validate`, which exits 8 when it finds any error-severity issue (a lost placeholder, rewritten ICU structure, or a target file it could not read). Warnings alone leave `sync validate` at exit 0. + This exit code is useful in CI/CD pipelines or scripts to detect when text could be improved: ```bash @@ -267,18 +274,18 @@ Supported formats: `audio/ogg`, `audio/webm`, `audio/flac`, `audio/mpeg`, `audio ### Style or tone not applied -**Cause:** The formality or style parameter may not be supported for the target language, or the text already matches the requested style. +**Cause:** The requested `--style` or `--tone` may not be supported for the target language, or the text already matches it. `deepl write` has no `--formality` flag — formality is a `deepl translate` / `deepl voice` option; on Write, register is controlled by `--style` (`simple`, `business`, `academic`, `casual`, and their `prefer_*` forms) and `--tone` (`enthusiastic`, `friendly`, `confident`, `diplomatic`, and their `prefer_*` forms). **Solutions:** -1. Not all languages support formality settings. Check the DeepL API documentation for supported languages. - -2. Verify you are using valid formality values: +1. Check the accepted values: ```bash deepl write --help ``` +2. Use a `prefer_*` value (e.g. `--style prefer_business`) so the API falls back rather than rejecting the request when the exact style is unavailable for that language. + 3. Use `--verbose` to inspect the API response and confirm the style was applied. ### Empty or unchanged output @@ -336,10 +343,11 @@ The config file location depends on your setup (see [Configuration Paths](../REA deepl config list ``` -2. Reset a specific setting: +2. Overwrite a specific setting, or reset the whole config: ```bash deepl config set + deepl config reset # clears every stored setting; add --yes to skip the prompt ``` 3. If the config file is corrupted, remove it and reconfigure: @@ -354,6 +362,25 @@ The config file location depends on your setup (see [Configuration Paths](../REA export DEEPL_CONFIG_DIR=/path/to/config ``` +### "Another sync is already running" (Exit Code 7) + +**Cause:** `deepl sync`, `deepl sync pull` and `deepl sync resolve` take a per-project lock by writing `.deepl-sync.lock.pidfile`. A second invocation that finds a pidfile naming a live PID exits 7 rather than letting two runs overwrite each other's target files and lockfile. + +**Solutions:** + +1. Wait for the other run, or find it: the error names the PID and the time it started. +2. If that sync is definitely not running — a crashed run whose PID has been recycled — take the lock explicitly: + + ```bash + deepl sync --break-lock + ``` + + Deleting `.deepl-sync.lock.pidfile` by hand does the same thing. Both are unsafe if the sync really is running. + +3. A pidfile whose PID is simply gone is reclaimed automatically with a warning; no action needed. + +See [docs/SYNC.md — Concurrent sync](SYNC.md#concurrent-sync) for the full arbitration rules, and that guide's [Troubleshooting](SYNC.md#troubleshooting) section for sync-specific issues behind exit codes 10, 11 and 12. + --- ## Input Validation Errors (Exit Code 6) @@ -388,6 +415,14 @@ deepl languages --source deepl languages --target ``` +### Unsupported Node.js version + +The CLI exits 6 with a single line naming the required and the running version — `requires Node.js >= 24.15.0, you are running v22.11.0. Upgrade Node.js to use the DeepL CLI.` + +**Cause:** The CLI requires Node.js 24.15.0 or later and checks the version at startup, before loading anything else, so an unsupported runtime gets that one line instead of an experimental-module warning or a later crash. The floor is a minor version because `node:sqlite` emits `ExperimentalWarning` on every earlier 24.x, including 24.0.0 — so a Node that satisfies "24 or later" is not necessarily enough. + +**Solution:** upgrade Node.js — e.g. `nvm install 24 && nvm use 24`, or install Node 24 from [nodejs.org](https://nodejs.org/); both give a current 24.x, which is past the floor. Confirm with `node --version` that the runtime invoking `deepl` is the upgraded one; a globally linked CLI can otherwise still run under an older default. + --- ## Cache Issues @@ -419,11 +454,11 @@ deepl cache enable ### "Translation cache backend failed to load" -**Cause:** The cache uses Node's built-in `node:sqlite` module, which requires Node.js 24 or later (the CLI's minimum supported version). On an older runtime the module doesn't exist, so caching cannot start. +**Cause:** The cache uses Node's built-in `node:sqlite` module and the runtime could not load it. Running on Node.js older than 24.15.0 is reported earlier and separately — see [Unsupported Node.js version](#unsupported-nodejs-version) — so what reaches this message is a runtime that reports a supported version but still has no usable `node:sqlite`: a Node built without SQLite support, or a non-Node runtime claiming a compatible version. -Translation and write commands keep working with caching disabled for the run; your cache database is not modified. `deepl cache` subcommands fail until the CLI runs on a supported Node.js version. +Translation and write commands keep working with caching disabled for the run; your cache database is not modified. `deepl cache` subcommands fail until the module loads. -**Solution:** run the CLI with Node.js 24 or later — e.g. `nvm install 24 && nvm use 24`, or install Node 24 from [nodejs.org](https://nodejs.org/). +**Solution:** run the CLI on an official Node.js build at 24.15.0 or later — e.g. `nvm install 24 && nvm use 24`, or install Node 24 from [nodejs.org](https://nodejs.org/). To confirm the module is the problem, check that `node -e "require('node:sqlite')"` succeeds on the same runtime. --- @@ -439,7 +474,7 @@ deepl translate ./docs --to es --output ./docs-es ### Unsupported document format -Supported document formats: PDF, DOCX, DOC, PPTX, XLSX, TXT, HTML, HTM, XLF, XLIFF, SRT, JPG, JPEG, PNG. See [docs/API.md](API.md) for the complete list of supported formats. +Supported: PDF, DOCX, DOC, PPTX, XLSX, JPG, JPEG, PNG (document API); TXT, HTML, HTM, SRT, XLF, XLIFF (routed by size); MD (cached text API); JSON, YAML, YML (structured file API — string values extracted, translated, and reassembled). See [docs/API.md](API.md) for the per-format routing rules and size limits. ```bash deepl translate document.docx --to fr --output translated.docx @@ -475,7 +510,7 @@ deepl translate document.docx --to fr --output translated.docx | 5 | Network error | Yes | | 6 | Invalid input | No | | 7 | Configuration error | No | -| 8 | Check found issues (write --check) | No | +| 8 | Check found issues (`write --check`, `correct --check`, `sync validate`) | No | | 9 | Voice API error | No | | 10 | Sync drift detected (sync --frozen) | No | | 11 | Sync lockfile conflict | No | diff --git a/examples/01-basic-translation.sh b/examples/01-basic-translation.sh index c1507e98..5eeddc46 100755 --- a/examples/01-basic-translation.sh +++ b/examples/01-basic-translation.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 1: Basic Translation +# Basic Translation # Demonstrates simple text translation with various options set -e # Exit on error -echo "=== DeepL CLI Example 1: Basic Translation ===" +echo "=== DeepL CLI: Basic Translation ===" echo # Check if API key is configured diff --git a/examples/02-file-translation.sh b/examples/02-file-translation.sh index b4552d14..75180fae 100755 --- a/examples/02-file-translation.sh +++ b/examples/02-file-translation.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 2: File Translation & Caching +# File Translation & Caching # Demonstrates translating files with format preservation and smart caching set -e # Exit on error -echo "=== DeepL CLI Example 2: File Translation ===" +echo "=== DeepL CLI: File Translation ===" echo # Check if API key is configured diff --git a/examples/03-batch-processing.sh b/examples/03-batch-processing.sh index 71e54ddc..7c07b23f 100755 --- a/examples/03-batch-processing.sh +++ b/examples/03-batch-processing.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 3: Batch Processing +# Batch Processing # Demonstrates processing multiple files efficiently set -e # Exit on error -echo "=== DeepL CLI Example 3: Batch Processing ===" +echo "=== DeepL CLI: Batch Processing ===" echo # Check if API key is configured @@ -172,7 +172,7 @@ for file in "$SAMPLE_DIR"/*.md; do echo " [$CURRENT/$TOTAL - $PERCENT%] Processing $filename..." # Translate (use cache for speed) - deepl translate "$file" --to ja --output "$OUTPUT_DIR/ja/" 2>/dev/null || echo " (cached or completed)" + deepl translate "$file" --to ja --output "$OUTPUT_DIR/ja/" done echo " ✓ Batch processing complete" @@ -210,7 +210,7 @@ deepl cache clear --yes >/dev/null 2>&1 START=$(date +%s) for file in "$SAMPLE_DIR"/*.md; do - deepl translate "$file" --to zh --output "$OUTPUT_DIR/zh/" >/dev/null 2>&1 || true + deepl translate "$file" --to zh --output "$OUTPUT_DIR/zh/" >/dev/null done END=$(date +%s) DURATION=$((END - START)) @@ -222,7 +222,7 @@ echo " Second run (cache hit):" START=$(date +%s) for file in "$SAMPLE_DIR"/*.md; do - deepl translate "$file" --to zh --output "$OUTPUT_DIR/zh/" >/dev/null 2>&1 || true + deepl translate "$file" --to zh --output "$OUTPUT_DIR/zh/" >/dev/null done END=$(date +%s) CACHED_DURATION=$((END - START)) diff --git a/examples/04-context-aware-translation.sh b/examples/04-context-aware-translation.sh index 8cf02ee3..292b258c 100755 --- a/examples/04-context-aware-translation.sh +++ b/examples/04-context-aware-translation.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 4: Context-Aware Translation +# Context-Aware Translation # Demonstrates using context to improve translation quality set -e # Exit on error -echo "=== DeepL CLI Example 4: Context-Aware Translation ===" +echo "=== DeepL CLI: Context-Aware Translation ===" echo # Check if API key is configured diff --git a/examples/05-document-translation.sh b/examples/05-document-translation.sh index 7239d22c..6d56a5c7 100755 --- a/examples/05-document-translation.sh +++ b/examples/05-document-translation.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 5: Document Translation +# Document Translation # Demonstrates translating complete documents while preserving formatting set -e # Exit on error -echo "=== DeepL CLI Example 5: Document Translation ===" +echo "=== DeepL CLI: Document Translation ===" echo # Check if API key is configured diff --git a/examples/06-document-format-conversion.sh b/examples/06-document-format-conversion.sh index 53b47ccc..5ced25e5 100755 --- a/examples/06-document-format-conversion.sh +++ b/examples/06-document-format-conversion.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 6: Document Format Conversion +# Document Format Conversion # Demonstrates the --output-format flag for document translation set -e # Exit on error -echo "=== DeepL CLI Example 6: Document Format Conversion ===" +echo "=== DeepL CLI: Document Format Conversion ===" echo # Check if API key is configured diff --git a/examples/07-structured-file-translation.sh b/examples/07-structured-file-translation.sh index ed0aa295..812e1aa5 100755 --- a/examples/07-structured-file-translation.sh +++ b/examples/07-structured-file-translation.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 7: Structured File Translation (JSON/YAML) +# Structured File Translation (JSON/YAML) # Demonstrates translating i18n locale files while preserving structure set -e # Exit on error -echo "=== DeepL CLI Example 7: Structured File Translation ===" +echo "=== DeepL CLI: Structured File Translation ===" echo # Check if API key is configured diff --git a/examples/08-model-type-selection.sh b/examples/08-model-type-selection.sh index 90f2230e..aa8431a0 100755 --- a/examples/08-model-type-selection.sh +++ b/examples/08-model-type-selection.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 8: Model Type Selection +# Model Type Selection # Demonstrates using different model types for quality vs. speed trade-offs set -e # Exit on error -echo "=== DeepL CLI Example 8: Model Type Selection ===" +echo "=== DeepL CLI: Model Type Selection ===" echo # Check if API key is configured diff --git a/examples/09-xml-tag-handling.sh b/examples/09-xml-tag-handling.sh index 546a6e69..f4aba09b 100755 --- a/examples/09-xml-tag-handling.sh +++ b/examples/09-xml-tag-handling.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 9: Advanced XML Tag Handling +# Advanced XML Tag Handling # Demonstrates fine-tuned control over XML/HTML translation with tag handling parameters set -e # Exit on error -echo "=== DeepL CLI Example 9: Advanced XML Tag Handling ===" +echo "=== DeepL CLI: Advanced XML Tag Handling ===" echo # Check if API key is configured diff --git a/examples/10-custom-instructions.sh b/examples/10-custom-instructions.sh index 4d179c79..dd7bfabc 100755 --- a/examples/10-custom-instructions.sh +++ b/examples/10-custom-instructions.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 10: Custom Instructions +# Custom Instructions # Use custom instructions to guide DeepL translations with specific rules set -e -echo "=== DeepL CLI Example 10: Custom Instructions ===" +echo "=== DeepL CLI: Custom Instructions ===" echo # Check if API key is configured diff --git a/examples/11-table-output.sh b/examples/11-table-output.sh index e6189281..0fcda2a4 100755 --- a/examples/11-table-output.sh +++ b/examples/11-table-output.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 11: Table Output Format +# Table Output Format # Demonstrates structured table output for comparing translations across multiple languages set -e # Exit on error -echo "=== DeepL CLI Example 11: Table Output Format ===" +echo "=== DeepL CLI: Table Output Format ===" echo # Check if API key is configured diff --git a/examples/12-cost-transparency.sh b/examples/12-cost-transparency.sh index 3e1955ed..97cf5e21 100755 --- a/examples/12-cost-transparency.sh +++ b/examples/12-cost-transparency.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 12: Cost Transparency with Billed Characters +# Cost Transparency with Billed Characters # Demonstrates tracking actual billed characters for translation cost analysis set -e # Exit on error -echo "=== DeepL CLI Example 12: Cost Transparency ===" +echo "=== DeepL CLI: Cost Transparency ===" echo # Check if API key is configured @@ -89,7 +89,7 @@ cat > "$TEMP_DIR/technical.md" << 'EOF' Run the following command: ```bash -npm install deepl-cli +npm install -g @deepl/cli ``` Then configure your API key: `deepl auth set-key YOUR_KEY` diff --git a/examples/13-write.sh b/examples/13-write.sh index 287ad185..a1f64434 100755 --- a/examples/13-write.sh +++ b/examples/13-write.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 13: DeepL Write API +# DeepL Write API # Demonstrates grammar, style, and tone enhancement set -e # Exit on error -echo "=== DeepL CLI Example 13: DeepL Write API ===" +echo "=== DeepL CLI: DeepL Write API ===" echo # Check if API key is configured @@ -19,52 +19,52 @@ echo # Basic text improvement echo "1. Basic text improvement:" -deepl write "This is a sentence." --lang en-US +deepl write "This is a sentence." --lang en-us echo # Business writing style echo "2. Business writing style:" -deepl write "We want to tell you about our new product." --lang en-US --style business +deepl write "We want to tell you about our new product." --lang en-us --style business echo # Academic writing style echo "3. Academic writing style:" -deepl write "This shows that the method works." --lang en-US --style academic +deepl write "This shows that the method works." --lang en-us --style academic echo # Casual writing style echo "4. Casual writing style:" -deepl write "That is interesting." --lang en-US --style casual +deepl write "That is interesting." --lang en-us --style casual echo # Simple writing style echo "5. Simple writing style:" -deepl write "The implementation demonstrates efficacy." --lang en-US --style simple +deepl write "The implementation demonstrates efficacy." --lang en-us --style simple echo # Enthusiastic tone echo "6. Enthusiastic tone:" -deepl write "This is good." --lang en-US --tone enthusiastic +deepl write "This is good." --lang en-us --tone enthusiastic echo # Friendly tone echo "7. Friendly tone:" -deepl write "Hello." --lang en-US --tone friendly +deepl write "Hello." --lang en-us --tone friendly echo # Confident tone echo "8. Confident tone:" -deepl write "I think this will work." --lang en-US --tone confident +deepl write "I think this will work." --lang en-us --tone confident echo # Diplomatic tone echo "9. Diplomatic tone:" -deepl write "Try something else." --lang en-US --tone diplomatic +deepl write "Try something else." --lang en-us --tone diplomatic echo # Show alternatives echo "10. Show all alternative improvements:" -deepl write "This is a test." --lang en-US --alternatives +deepl write "This is a test." --lang en-us --alternatives echo # Different languages @@ -82,11 +82,11 @@ echo # Prefer styles (fallback if not supported) echo "14. Prefer business style (with fallback):" -deepl write "We need to discuss this." --lang en-US --style prefer_business +deepl write "We need to discuss this." --lang en-us --style prefer_business echo echo "15. Bypass cache (always call API):" -deepl write "This is a sentence." --lang en-US --no-cache +deepl write "This is a sentence." --lang en-us --no-cache echo # ═══════════════════════════════════════════════════════ @@ -97,11 +97,11 @@ DEMO_FILE="/tmp/deepl-write-demo.txt" echo "Their going to the store tommorow. The weather will be good, I think we should definately go." > "$DEMO_FILE" echo "16. Improve text from a file:" -deepl write "$DEMO_FILE" --lang en-US +deepl write "$DEMO_FILE" --lang en-us echo echo "17. Write improved text to output file:" -deepl write "$DEMO_FILE" --output /tmp/deepl-write-improved.txt --lang en-US +deepl write "$DEMO_FILE" --output /tmp/deepl-write-improved.txt --lang en-us echo " Output saved to /tmp/deepl-write-improved.txt" cat /tmp/deepl-write-improved.txt echo @@ -111,11 +111,15 @@ echo # ═══════════════════════════════════════════════════════ echo "18. Check if text needs improvement (exit 0=clean, 8=changes needed):" -deepl write "Their going to the store" --check --lang en-US || true +deepl write "Their going to the store" --check --lang en-us || true echo echo "19. Check a file for improvements:" -deepl write "$DEMO_FILE" --check --lang en-US || true +deepl write "$DEMO_FILE" --check --lang en-us || true +echo + +echo "19b. Machine-readable check result for a CI gate:" +deepl write "$DEMO_FILE" --check --lang en-us --format json || true echo echo "20. Auto-fix a file in place:" @@ -136,12 +140,20 @@ echo # ═══════════════════════════════════════════════════════ echo "22. Show diff between original and improved text:" -deepl write "Their going to the store tommorow." --diff --lang en-US +deepl write "Their going to the store tommorow." --diff --lang en-us +echo + +echo "22b. Machine-readable diff (uncoloured patch in the payload):" +deepl write "Their going to the store tommorow." --diff --lang en-us --format json +echo + +echo "22c. Every alternative as a JSON array:" +deepl write "This is a test." --lang en-us --alternatives --format json echo echo "23. Edit file in place:" echo "This text could be more better." > "$DEMO_FILE" -deepl write "$DEMO_FILE" --in-place --lang en-US +deepl write "$DEMO_FILE" --in-place --lang en-us echo " Updated file content:" cat "$DEMO_FILE" echo @@ -151,7 +163,7 @@ echo # ═══════════════════════════════════════════════════════ echo "24. JSON output format:" -deepl write "Their going to the store" --format json --lang en-US +deepl write "Their going to the store" --format json --lang en-us echo # ═══════════════════════════════════════════════════════ @@ -160,7 +172,7 @@ echo # Note: --interactive requires a TTY (won't work in piped scripts) echo "25. Interactive mode (choose from multiple suggestions):" -echo " deepl write \"Their going to the store\" --interactive --lang en-US" +echo " deepl write \"Their going to the store\" --interactive --lang en-us" echo " (Skipped in non-interactive script — try this manually)" echo diff --git a/examples/36-write-extended-languages.sh b/examples/14-write-extended-languages.sh similarity index 75% rename from examples/36-write-extended-languages.sh rename to examples/14-write-extended-languages.sh index f01a74cf..41ec73dc 100755 --- a/examples/36-write-extended-languages.sh +++ b/examples/14-write-extended-languages.sh @@ -1,11 +1,11 @@ #!/bin/bash -# Example 36: Write — extended language coverage -# Demonstrates JA/KO/ZH/zh-Hans target languages and +# Write — extended language coverage +# Demonstrates ja/ko/zh/zh-hans target languages and # tone / style applied to ES/IT/FR/PT variants. set -e -echo "=== DeepL CLI Example 36: Write — Extended Language Coverage ===" +echo "=== DeepL CLI: Write — Extended Language Coverage ===" echo if ! deepl auth show &>/dev/null; then @@ -30,8 +30,8 @@ echo "3. Simplified Chinese target (zh)" deepl write "请改进这句话。" --lang zh echo -echo "4. Simplified Chinese target (zh-Hans)" -deepl write "请改进这句话。" --lang zh-Hans +echo "4. Simplified Chinese target (zh-hans)" +deepl write "请改进这句话。" --lang zh-hans echo # Tone / style on Romance variants @@ -48,11 +48,11 @@ deepl write "Les résultats montrent une corrélation." --lang fr --style academ echo echo "8. Portuguese (Brazil) + friendly tone" -deepl write "Podemos ajudar com isso." --lang pt-BR --tone friendly +deepl write "Podemos ajudar com isso." --lang pt-br --tone friendly echo echo "9. Portuguese (Portugal) + confident tone" -deepl write "Vamos entregar no prazo." --lang pt-PT --tone confident +deepl write "Vamos entregar no prazo." --lang pt-pt --tone confident echo # Auto-detect round-trip for a CJK input diff --git a/examples/37-correct.sh b/examples/15-correct.sh similarity index 69% rename from examples/37-correct.sh rename to examples/15-correct.sh index 667a8c93..739c2ca2 100755 --- a/examples/37-correct.sh +++ b/examples/15-correct.sh @@ -1,11 +1,11 @@ #!/bin/bash -# Example 37: Correct — spelling and grammar correction without rewording +# Correct — spelling and grammar correction without rewording # Demonstrates the correct command: basic use, alias, check mode, # fix with backup, diff view, and JSON output. set -e -echo "=== DeepL CLI Example 37: Correct — Spelling and Grammar ===" +echo "=== DeepL CLI: Correct — Spelling and Grammar ===" echo if ! deepl auth show &>/dev/null; then @@ -26,7 +26,7 @@ deepl correct "This is an test with some mistaks." echo echo "2. Explicit target language" -deepl correct "Their going too the store." --lang en-US +deepl correct "Their going too the store." --lang en-us echo echo "3. The c alias" @@ -34,7 +34,11 @@ deepl c "I has a apple." echo echo "4. Diff view" -deepl correct "This are a example sentence." --lang en-US --diff +deepl correct "This are a example sentence." --lang en-us --diff +echo + +echo "4b. Diff view as a machine-readable payload" +deepl correct "This are a example sentence." --lang en-us --diff --format json echo echo "5. Check mode (exit 8 would mean corrections needed)" @@ -51,6 +55,10 @@ else fi echo +echo "5b. Check mode as a machine-readable result (ok stays true; the verdict is needsChanges)" +deepl correct "$TEST_DIR/draft.txt" --check --format json || true +echo + echo "6. Fix a file in place with a backup" deepl correct "$TEST_DIR/draft.txt" --fix --backup echo "Fixed content:" diff --git a/examples/14-voice.sh b/examples/16-voice.sh similarity index 97% rename from examples/14-voice.sh rename to examples/16-voice.sh index fafbb856..29b88932 100755 --- a/examples/14-voice.sh +++ b/examples/16-voice.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 14: Voice (Real-Time Speech Translation) +# Voice (Real-Time Speech Translation) # Translate audio files using the DeepL Voice API with WebSocket streaming set -e -echo "=== DeepL CLI Example 14: Voice ===" +echo "=== DeepL CLI: Voice ===" echo # Check if API key is configured diff --git a/examples/15-glossaries.sh b/examples/17-glossaries.sh similarity index 88% rename from examples/15-glossaries.sh rename to examples/17-glossaries.sh index 5eb921c3..2f6659e7 100755 --- a/examples/15-glossaries.sh +++ b/examples/17-glossaries.sh @@ -1,11 +1,11 @@ #!/bin/bash -# Example 15: Glossaries (v3 API) +# Glossaries (v3 API) # Demonstrates managing glossaries for consistent terminology # v3 API supports both single-target and multilingual glossaries set -e # Exit on error -echo "=== DeepL CLI Example 15: Glossaries (v3 API) ===" +echo "=== DeepL CLI: Glossaries (v3 API) ===" echo # Check if API key is configured @@ -35,6 +35,13 @@ request Anfrage response Antwort EOF +# Create an override glossary (EN → DE) that redefines one term from the tech +# glossary, so the precedence between two glossaries is visible in the output +cat > "$SAMPLE_DIR/override-glossary.tsv" << 'EOF' +endpoint Schnittstelle +webhook Webhook +EOF + # Create a business terminology glossary (EN → ES) cat > "$SAMPLE_DIR/business-glossary.tsv" << 'EOF' stakeholder parte interesada @@ -59,7 +66,7 @@ echo # from a clean slate. echo "0. Pre-run cleanup of any leftover demo glossaries" if command -v jq &>/dev/null; then - DEMO_NAMES='tech-terms-demo tech-terms-renamed tech-final business-terms-demo multi-demo' + DEMO_NAMES='tech-terms-demo tech-terms-renamed tech-final business-terms-demo multi-demo override-terms-demo' for name in $DEMO_NAMES; do deepl glossary list --format json 2>/dev/null \ | jq -r --arg n "$name" '.[] | select(.name == $n) | .glossary_id' 2>/dev/null \ @@ -195,6 +202,22 @@ deepl translate "The API endpoint requires authentication." --from en --to de -- echo +echo " Repeat --glossary to apply up to 5 glossaries to one request." +echo " Entries are merged; on a term both define, the LAST glossary wins." +deepl glossary create override-terms-demo en de "$SAMPLE_DIR/override-glossary.tsv" + +echo +echo " Both glossaries, override last -> 'endpoint' becomes Schnittstelle:" +deepl translate "The API endpoint requires authentication." --from en --to de \ + --glossary tech-terms-renamed --glossary override-terms-demo + +echo +echo " Same two glossaries reversed -> 'endpoint' stays Endpunkt:" +deepl translate "The API endpoint requires authentication." --from en --to de \ + --glossary override-terms-demo --glossary tech-terms-renamed + +echo + # ═══════════════════════════════════════════════════════ # ADVANCED OPERATIONS # ═══════════════════════════════════════════════════════ @@ -231,6 +254,9 @@ deepl glossary delete tech-final --yes 2>/dev/null || echo " (Already deleted) echo " Deleting business-terms-demo..." deepl glossary delete business-terms-demo --yes 2>/dev/null || echo " (Already deleted)" +echo " Deleting override-terms-demo..." +deepl glossary delete override-terms-demo --yes 2>/dev/null || echo " (Already deleted)" + echo " Deleting multi-demo..." deepl glossary delete multi-demo --yes 2>/dev/null || echo " (Already deleted)" diff --git a/examples/33-tm-list.sh b/examples/18-tm-list.sh similarity index 89% rename from examples/33-tm-list.sh rename to examples/18-tm-list.sh index 67079587..a864b929 100755 --- a/examples/33-tm-list.sh +++ b/examples/18-tm-list.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Example 33: Translation Memory listing +# Translation Memory listing # Lists all translation memories on the current DeepL account. # TM files are authored and uploaded via the DeepL web UI; this CLI # surfaces them so you can copy a UUID or name into a translate or @@ -7,7 +7,7 @@ set -e -echo "=== DeepL CLI Example 33: deepl tm list ===" +echo "=== DeepL CLI: deepl tm list ===" echo if ! deepl auth show &>/dev/null; then diff --git a/examples/16-watch-mode.sh b/examples/19-watch-mode.sh similarity index 98% rename from examples/16-watch-mode.sh rename to examples/19-watch-mode.sh index 069d65f9..10f98086 100755 --- a/examples/16-watch-mode.sh +++ b/examples/19-watch-mode.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 16: Watch Mode +# Watch Mode # Demonstrates real-time file monitoring and auto-translation set -e # Exit on error -echo "=== DeepL CLI Example 16: Watch Mode ===" +echo "=== DeepL CLI: Watch Mode ===" echo # Check if API key is configured diff --git a/examples/17-git-hooks.sh b/examples/20-git-hooks.sh similarity index 98% rename from examples/17-git-hooks.sh rename to examples/20-git-hooks.sh index e9f6e9be..f776eebb 100755 --- a/examples/17-git-hooks.sh +++ b/examples/20-git-hooks.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 17: Git Hooks Integration +# Git Hooks Integration # Demonstrates automating translation validation in git workflow set -e # Exit on error -echo "=== DeepL CLI Example 17: Git Hooks Integration ===" +echo "=== DeepL CLI: Git Hooks Integration ===" echo # Check if API key is configured diff --git a/examples/18-cicd-integration.sh b/examples/21-cicd-integration.sh similarity index 98% rename from examples/18-cicd-integration.sh rename to examples/21-cicd-integration.sh index 17c7b61f..0143fb4d 100755 --- a/examples/18-cicd-integration.sh +++ b/examples/21-cicd-integration.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 18: CI/CD Integration +# CI/CD Integration # Demonstrates using DeepL CLI in automated workflows set -e # Exit on error -echo "=== DeepL CLI Example 18: CI/CD Integration ===" +echo "=== DeepL CLI: CI/CD Integration ===" echo # Check if API key is configured @@ -136,7 +136,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' - name: Install DeepL CLI run: npm install -g @deepl/cli diff --git a/examples/30-sync-basic.sh b/examples/22-sync-basic.sh similarity index 98% rename from examples/30-sync-basic.sh rename to examples/22-sync-basic.sh index a23316e1..f90a0452 100755 --- a/examples/30-sync-basic.sh +++ b/examples/22-sync-basic.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 30: Sync — Basic Usage +# Sync — Basic Usage # Demonstrates scanning, diffing, and syncing i18n locale files set -e # Exit on error -echo "=== DeepL CLI Example 30: Sync — Basic Usage ===" +echo "=== DeepL CLI: Sync — Basic Usage ===" echo # Check if API key is configured diff --git a/examples/31-sync-ci.sh b/examples/23-sync-ci.sh similarity index 94% rename from examples/31-sync-ci.sh rename to examples/23-sync-ci.sh index 746a672b..6212eefd 100755 --- a/examples/31-sync-ci.sh +++ b/examples/23-sync-ci.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 31: Sync — CI/CD Integration +# Sync — CI/CD Integration # Demonstrates using deepl sync in automated pipelines set -e # Exit on error -echo "=== DeepL CLI Example 31: Sync — CI/CD Integration ===" +echo "=== DeepL CLI: Sync — CI/CD Integration ===" echo # Check if API key is configured @@ -117,6 +117,9 @@ cat << 'WORKFLOW' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 - run: npm install -g @deepl/cli - name: Check translations are up to date run: deepl sync --frozen @@ -131,7 +134,7 @@ echo cat << 'GITLAB' i18n-check: stage: test - image: node:20 + image: node:24 script: - npm install -g @deepl/cli - deepl sync --frozen @@ -158,6 +161,9 @@ cat << 'AUTOSYNC' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 - run: npm install -g @deepl/cli - name: Sync translations run: deepl sync --format json diff --git a/examples/32-sync-live-validation.sh b/examples/24-sync-live-validation.sh similarity index 97% rename from examples/32-sync-live-validation.sh rename to examples/24-sync-live-validation.sh index c020f878..b7b2de3e 100755 --- a/examples/32-sync-live-validation.sh +++ b/examples/24-sync-live-validation.sh @@ -1,12 +1,12 @@ #!/bin/bash -# Example 32: Sync — Live API Validation +# Sync — Live API Validation # Comprehensive end-to-end validation of all sync features against the real DeepL API. # Exercises: multi-format, placeholders, plurals, incremental sync, new locales, # frozen mode, status, validate, force, and context extraction. set -euo pipefail -echo "=== DeepL CLI Example 32: Sync — Live API Validation ===" +echo "=== DeepL CLI: Sync — Live API Validation ===" echo # Check API key @@ -208,7 +208,9 @@ echo "Phase 5: Status + Validate" deepl sync status 2>&1 | head -10 assert_exit_code "status exits 0" 0 deepl sync status -STATUS_JSON=$(deepl sync status --format json 2>&1) +# stdout only: the JSON payload lands there in both the success and the +# failure case, and merging stderr would splice warnings into the capture. +STATUS_JSON=$(deepl sync status --format json) # Wrap pipelines inside helper functions so the assert helper runs them # atomically. Otherwise `assert "desc" echo $X | python3 ...` parses the # pipe at the assert call level — assert sees only `echo $X` (always 0) diff --git a/examples/34-sync-laravel-php.sh b/examples/25-sync-laravel-php.sh similarity index 97% rename from examples/34-sync-laravel-php.sh rename to examples/25-sync-laravel-php.sh index b9507e67..ac1fd4fc 100755 --- a/examples/34-sync-laravel-php.sh +++ b/examples/25-sync-laravel-php.sh @@ -1,11 +1,11 @@ #!/bin/bash -# Example 34: Sync — Laravel PHP arrays +# Sync — Laravel PHP arrays # Demonstrates the laravel_php format parser: AST allowlist, span-surgical # reconstruct, pipe-pluralization warning gate, and sync.limits caps. set -e -echo "=== DeepL CLI Example 34: Sync — Laravel PHP arrays ===" +echo "=== DeepL CLI: Sync — Laravel PHP arrays ===" echo if ! deepl auth show &>/dev/null; then diff --git a/examples/26-sync-resolve.sh b/examples/26-sync-resolve.sh new file mode 100755 index 00000000..84f8237d --- /dev/null +++ b/examples/26-sync-resolve.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# Sync — Resolving lockfile merge conflicts +# Demonstrates `deepl sync resolve` on a .deepl-sync.lock left conflicted by git + +set -e # Exit on error + +echo "=== DeepL CLI: Sync — Resolve Lockfile Conflicts ===" +echo + +# resolve reads and rewrites .deepl-sync.lock and never calls the API, so this +# example runs without a key or a network. +echo "No API key needed: resolve works on the lockfile alone." +echo + +PROJECT_DIR="/tmp/deepl-sync-resolve-demo" +rm -rf "$PROJECT_DIR" +mkdir -p "$PROJECT_DIR/locales" + +cleanup() { + rm -rf "$PROJECT_DIR" +} +trap cleanup EXIT + +cd "$PROJECT_DIR" + +cat > .deepl-sync.yaml << 'EOF' +version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +EOF + +echo '{ "greeting": "Hello" }' > locales/en.json +echo '{ "greeting": "Hallo" }' > locales/de.json + +echo "Created test project: $PROJECT_DIR" +echo + +# 1. A lockfile in the state git leaves behind when two branches both +# re-translated the same key. Both sides are valid JSON; they differ only in +# the German translation and its timestamp. +echo "1. A .deepl-sync.lock left conflicted by git merge" +cat > .deepl-sync.lock << 'EOF' +{ + "version": 1, + "generated_at": "2026-01-01T00:00:00.000Z", + "source_locale": "en", + "entries": { + "locales/en.json": { +<<<<<<< HEAD + "greeting": { "source_hash": "185f8db32271", "source_text": "Hello", "source_locale": "en", "updated_at": "2026-01-01T00:00:00.000Z", "translations": { "de": { "locale": "de", "hash": "185f8db32271", "status": "translated", "translated_at": "2026-02-01T00:00:00.000Z" } } } +======= + "greeting": { "source_hash": "185f8db32271", "source_text": "Hello", "source_locale": "en", "updated_at": "2026-01-01T00:00:00.000Z", "translations": { "de": { "locale": "de", "hash": "185f8db32271", "status": "translated", "translated_at": "2026-03-01T00:00:00.000Z" } } } +>>>>>>> feature/de-updates + } + }, + "stats": { "total_keys": 1, "total_translations": 1, "last_sync": "2026-01-01T00:00:00.000Z" } +} +EOF +grep -c '<<<<<<<\|=======\|>>>>>>>' .deepl-sync.lock | xargs echo " conflict marker lines:" +echo " ours was translated 2026-02-01, theirs 2026-03-01" +echo + +# 2. --dry-run prints the decision it would make and leaves the file alone. +echo "2. Preview the decisions without writing (--dry-run)" +deepl sync resolve --dry-run +echo +echo " lockfile still conflicted after the dry run:" +grep -c '<<<<<<<' .deepl-sync.lock | xargs echo " remaining <<<<<<< markers:" +echo + +# 3. Apply. For each conflicting locale the newer translated_at wins, so the +# branch that translated later is the one kept. +echo "3. Resolve for real" +deepl sync resolve +echo + +echo "4. The newer side survived" +node -e ' +const fs = require("fs"); +const lock = JSON.parse(fs.readFileSync(".deepl-sync.lock", "utf8")); +const de = lock.entries["locales/en.json"].greeting.translations.de; +console.log(" kept translated_at:", de.translated_at); +console.log(" conflict markers remaining:", /<<<<<< .deepl-sync.lock << 'EOF' +{ + "version": 1, + "generated_at": "2026-01-01T00:00:00.000Z", + "source_locale": "en", + "entries": { + "locales/en.json": { +<<<<<<< HEAD + "greeting": { "source_hash": "185f8db32271", "source_text": "Hello", "source_locale": "en", "updated_at": "2026-01-01T00:00:00.000Z", "translations": { "de": { "locale": "de", "hash": "185f8db32271", "status": "translated", "translated_at": "2026-02-01T00:00:00.000Z" } } } +======= + "greeting": { "source_hash": "185f8db32271", "source_text": "Hello", "source_locale": "en", "updated_at": "2026-01-01T00:00:00.000Z", "translations": { "de": { "locale": "de", "hash": "185f8db32271", "status": "translated", "translated_at": "2026-03-01T00:00:00.000Z" } } } +>>>>>>> feature/de-updates + } + }, + "stats": { "total_keys": 1, "total_translations": 1, "last_sync": "2026-01-01T00:00:00.000Z" } +} +EOF +deepl sync resolve --format json +echo + +cat << 'EOF' + +Notes: + - Each conflicting locale is decided on its own translated_at; the later + translation wins and the decision names the locale it applied to. + - A conflict region whose sides do not parse as JSON falls back to keeping the + longer side. That decision is tagged length-heuristic and logged as a WARN; + review those by hand rather than trusting them. + - resolve only settles the lockfile. Run `deepl sync` afterwards to fill any + translation gaps the merge left behind. + +EOF + +echo "=== Resolve example complete ===" diff --git a/examples/27-sync-audit.sh b/examples/27-sync-audit.sh new file mode 100755 index 00000000..f8cf96dc --- /dev/null +++ b/examples/27-sync-audit.sh @@ -0,0 +1,123 @@ +#!/bin/bash +# Sync — Auditing translation consistency +# Demonstrates `deepl sync audit` finding one source term translated two ways + +set -e # Exit on error + +echo "=== DeepL CLI: Sync — Audit Translation Consistency ===" +echo + +# audit compares the lockfile against the translations already on disk and never +# calls the API, so this example runs without a key or a network. +echo "No API key needed: audit compares the lockfile against your target files." +echo + +PROJECT_DIR="/tmp/deepl-sync-audit-demo" +rm -rf "$PROJECT_DIR" +mkdir -p "$PROJECT_DIR/locales" + +cleanup() { + rm -rf "$PROJECT_DIR" +} +trap cleanup EXIT + +cd "$PROJECT_DIR" + +cat > .deepl-sync.yaml << 'EOF' +version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +EOF + +echo "Created test project: $PROJECT_DIR" +echo + +# 1. Two keys carrying the same English source text. This is the ordinary +# shape of a UI string reused in a button and a menu item. +echo "1. Source file with one term used twice" +cat > locales/en.json << 'EOF' +{ + "save_button": "Save", + "save_menu": "Save", + "cancel_button": "Cancel" +} +EOF +cat locales/en.json +echo + +# 2. German translated the two "Save" keys differently. Both are valid German; +# the point is that one product should not use both. +echo "2. German where the same term was translated two ways" +cat > locales/de.json << 'EOF' +{ + "save_button": "Speichern", + "save_menu": "Sichern", + "cancel_button": "Abbrechen" +} +EOF +cat locales/de.json +echo + +# 3. audit reads source_text and the per-locale status from the lockfile, then +# compares the actual translated strings in the target files. +cat > .deepl-sync.lock << 'EOF' +{ + "version": 1, + "generated_at": "2026-01-01T00:00:00.000Z", + "source_locale": "en", + "entries": { + "locales/en.json": { + "save_button": { "source_hash": "s1", "source_text": "Save", "source_locale": "en", "updated_at": "2026-01-01T00:00:00.000Z", "translations": { "de": { "locale": "de", "hash": "s1", "status": "translated", "translated_at": "2026-01-01T00:00:00.000Z" } } }, + "save_menu": { "source_hash": "s1", "source_text": "Save", "source_locale": "en", "updated_at": "2026-01-01T00:00:00.000Z", "translations": { "de": { "locale": "de", "hash": "s1", "status": "translated", "translated_at": "2026-01-01T00:00:00.000Z" } } }, + "cancel_button": { "source_hash": "c1", "source_text": "Cancel", "source_locale": "en", "updated_at": "2026-01-01T00:00:00.000Z", "translations": { "de": { "locale": "de", "hash": "c1", "status": "translated", "translated_at": "2026-01-01T00:00:00.000Z" } } } + } + }, + "stats": { "total_keys": 3, "total_translations": 3, "last_sync": "2026-01-01T00:00:00.000Z" } +} +EOF + +echo "3. Audit the project" +deepl sync audit +echo + +# 4. JSON for a CI step. inconsistencies[] is empty on a consistent project, so +# a pipeline can gate on its length. +echo "4. Machine-readable report (--format json)" +deepl sync audit --format json +echo + +# 5. Align the two translations and the inconsistency goes away. "Cancel" was +# never flagged: one source term with one translation is consistent. +echo "5. Align the two translations, then audit again" +cat > locales/de.json << 'EOF' +{ + "save_button": "Speichern", + "save_menu": "Speichern", + "cancel_button": "Abbrechen" +} +EOF +deepl sync audit +echo + +cat << 'EOF' + +Notes: + - "Audit" here means translation consistency — one source term rendered + differently across a project — not security auditing in the npm audit sense. + - Comparison uses the text in your target files, not the lockfile hashes: the + per-locale hash is a hash of the SOURCE text, so it is identical across a + term group by construction and cannot identify a translation. + - A target file that cannot be read or parsed is left out of the comparison + and listed under missingTargets rather than silently counted as consistent. + - Inconsistencies are reported, not corrected. Fix them by editing the target + file, or keep terminology aligned up front with a glossary + (see 17-glossaries.sh). + +EOF + +echo "=== Audit example complete ===" diff --git a/examples/19-configuration.sh b/examples/28-configuration.sh similarity index 97% rename from examples/19-configuration.sh rename to examples/28-configuration.sh index 65bb8086..21ab9060 100755 --- a/examples/19-configuration.sh +++ b/examples/28-configuration.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 19: Configuration +# Configuration # Demonstrates configuration management set -e # Exit on error -echo "=== DeepL CLI Example 19: Configuration ===" +echo "=== DeepL CLI: Configuration ===" echo # Check if API key is configured diff --git a/examples/20-custom-config-files.sh b/examples/29-custom-config-files.sh similarity index 97% rename from examples/20-custom-config-files.sh rename to examples/29-custom-config-files.sh index bf5c618d..677dce99 100755 --- a/examples/20-custom-config-files.sh +++ b/examples/29-custom-config-files.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 20: Custom Configuration Files +# Custom Configuration Files # Demonstrates using --config flag for multiple configurations set -e # Exit on error -echo "=== DeepL CLI Example 20: Custom Configuration Files ===" +echo "=== DeepL CLI: Custom Configuration Files ===" echo # Check if API key is configured diff --git a/examples/21-cache.sh b/examples/30-cache.sh similarity index 97% rename from examples/21-cache.sh rename to examples/30-cache.sh index 4156ecd9..ee107359 100755 --- a/examples/21-cache.sh +++ b/examples/30-cache.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 21: Cache Management +# Cache Management # Demonstrates working with the translation cache set -e # Exit on error -echo "=== DeepL CLI Example 21: Cache Management ===" +echo "=== DeepL CLI: Cache Management ===" echo # Check if API key is configured diff --git a/examples/22-style-rules.sh b/examples/31-style-rules.sh similarity index 96% rename from examples/22-style-rules.sh rename to examples/31-style-rules.sh index e4416200..c080d12b 100755 --- a/examples/22-style-rules.sh +++ b/examples/31-style-rules.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 22: Style Rules +# Style Rules # List and use DeepL style rules for consistent translation styles (Pro API only) set -e -echo "=== DeepL CLI Example 22: Style Rules ===" +echo "=== DeepL CLI: Style Rules ===" echo # Check if API key is configured diff --git a/examples/35-style-rules-crud.sh b/examples/32-style-rules-crud.sh similarity index 96% rename from examples/35-style-rules-crud.sh rename to examples/32-style-rules-crud.sh index fc4d6e07..7c4f7179 100755 --- a/examples/35-style-rules-crud.sh +++ b/examples/32-style-rules-crud.sh @@ -1,11 +1,11 @@ #!/bin/bash -# Example 35: Style Rules CRUD +# Style Rules CRUD # End-to-end walkthrough of creating, updating, and deleting a style rule, # including custom instruction management. Pro API only. set -e -echo "=== DeepL CLI Example 35: Style Rules CRUD ===" +echo "=== DeepL CLI: Style Rules CRUD ===" echo # Check if API key is configured diff --git a/examples/23-usage-monitoring.sh b/examples/33-usage-monitoring.sh similarity index 95% rename from examples/23-usage-monitoring.sh rename to examples/33-usage-monitoring.sh index 47504221..6d00085d 100755 --- a/examples/23-usage-monitoring.sh +++ b/examples/33-usage-monitoring.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 23: API Usage Monitoring +# API Usage Monitoring # Demonstrates checking API character usage and quota set -e # Exit on error -echo "=== DeepL CLI Example 23: API Usage Monitoring ===" +echo "=== DeepL CLI: API Usage Monitoring ===" echo # Check if API key is configured diff --git a/examples/24-languages.sh b/examples/34-languages.sh similarity index 65% rename from examples/24-languages.sh rename to examples/34-languages.sh index 6d403275..8c72214a 100755 --- a/examples/24-languages.sh +++ b/examples/34-languages.sh @@ -1,11 +1,11 @@ #!/bin/bash -# Example 24: List Supported Languages +# List Supported Languages # Demonstrates listing source and target languages supported by DeepL, # including extended languages and graceful degradation without an API key set -e # Exit on error -echo "=== DeepL CLI Example 24: Supported Languages ===" +echo "=== DeepL CLI: Supported Languages ===" echo # Check if API key is configured @@ -57,13 +57,46 @@ echo "6. Target languages in JSON format:" deepl languages --target --format json | head -10 echo +# Example 7: Which features each language supports +echo "7. Feature support per language" +echo " Columns only appear for features that differ between languages;" +echo " one supported by all of them is summarised on the last line." +deepl languages --target --features | head -12 +echo + +echo "8. The same matrix as columns" +deepl languages --target --features --format table | head -10 +echo + +# Example 9: Preflight a feature in a script +echo "9. Checking feature support before using a flag" +echo +cat << 'EOF' +# Bash snippet: only pass --glossary when the target supports glossaries. +supports() { # supports + deepl languages --target --features --format json \ + | jq -e --arg l "$1" --arg f "$2" \ + '.[] | select(.language == $l) | .features | has($f)' >/dev/null +} + +if supports th glossary; then + deepl translate --from en --to th --glossary my-terms "Hello" +else + echo "Thai does not support glossaries; translating without one" + deepl translate --to th "Hello" +fi +EOF +echo + echo "=== Language listing example completed! ====" echo echo "💡 Language categories:" echo " - Core (32): Full support - formality, glossaries, all model types" -echo " - Regional (7): Target-only variants (en-us, en-gb, pt-br, etc.)" +echo " - Regional (11): Target-only variants (en-us, en-gb, de-ch, fr-ca, pt-br, etc.)" echo " - Extended (82): quality_optimized only, no formality or glossaries" +echo " - --features is finer-grained than these tiers: some extended languages" +echo " do support style rules and translation memory" echo echo "📚 Language notes:" diff --git a/examples/25-detect.sh b/examples/35-detect.sh similarity index 94% rename from examples/25-detect.sh rename to examples/35-detect.sh index e15e2e49..d4790a81 100755 --- a/examples/25-detect.sh +++ b/examples/35-detect.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 25: Language Detection +# Language Detection # Demonstrates detecting the language of text using the DeepL API set -e -echo "=== DeepL CLI Example 25: Language Detection ===" +echo "=== DeepL CLI: Language Detection ===" echo if ! deepl auth show &>/dev/null; then diff --git a/examples/26-completion.sh b/examples/36-completion.sh similarity index 90% rename from examples/26-completion.sh rename to examples/36-completion.sh index daabe505..6cf56af4 100755 --- a/examples/26-completion.sh +++ b/examples/36-completion.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 26: Shell Completions +# Shell Completions # Demonstrates generating shell completion scripts for bash, zsh, and fish set -e -echo "=== DeepL CLI Example 26: Shell Completions ===" +echo "=== DeepL CLI: Shell Completions ===" echo echo "1. Generate bash completion (preview):" @@ -46,5 +46,5 @@ echo "=== Shell Completions Examples Complete ===" echo echo "Tips:" echo " - Completions cover all commands, subcommands, and flags" -echo " - Regenerate after upgrading deepl-cli" +echo " - Regenerate after upgrading @deepl/cli" echo " - Works without an API key" diff --git a/examples/27-admin.sh b/examples/37-admin.sh similarity index 97% rename from examples/27-admin.sh rename to examples/37-admin.sh index 6b21a3d1..9fa894b0 100755 --- a/examples/27-admin.sh +++ b/examples/37-admin.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 27: Admin API +# Admin API # Manage API keys and view organization usage analytics (requires admin API key) set -e -echo "=== DeepL CLI Example 27: Admin API ===" +echo "=== DeepL CLI: Admin API ===" echo echo "Note: Admin API requires an admin-level API key." echo "These commands manage developer keys and usage analytics for your organization." diff --git a/examples/28-init.sh b/examples/38-init.sh similarity index 96% rename from examples/28-init.sh rename to examples/38-init.sh index 05097707..7ef4b26b 100755 --- a/examples/28-init.sh +++ b/examples/38-init.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 28: Setup Wizard (deepl init) +# Setup Wizard (deepl init) # Demonstrates first-time interactive setup set -e # Exit on error -echo "=== DeepL CLI Example 28: Setup Wizard ===" +echo "=== DeepL CLI: Setup Wizard ===" echo # Note: deepl init is an interactive wizard that requires terminal input. diff --git a/examples/29-advanced-translate.sh b/examples/39-advanced-translate.sh similarity index 87% rename from examples/29-advanced-translate.sh rename to examples/39-advanced-translate.sh index 0b8777ee..51f0e179 100755 --- a/examples/29-advanced-translate.sh +++ b/examples/39-advanced-translate.sh @@ -1,10 +1,10 @@ #!/bin/bash -# Example 29: Advanced Translation Options +# Advanced Translation Options # Demonstrates tag handling versions, beta languages, custom API URLs, and minification set -e # Exit on error -echo "=== DeepL CLI Example 29: Advanced Translation Options ===" +echo "=== DeepL CLI: Advanced Translation Options ===" echo # Check if API key is configured @@ -49,22 +49,16 @@ echo echo "✓ Translation with v2 tag handling complete" echo echo "Tag handling versions:" -echo " --tag-handling-version v1 Original tag handling (default)" -echo " --tag-handling-version v2 Improved structure handling" +echo " --tag-handling-version v2 Improved structure handling (default)" +echo " --tag-handling-version v1 Original tag handling (heading for deprecation)" echo " (requires --tag-handling xml or --tag-handling html)" echo -# Example 2: Beta languages -echo "=== 2. Beta Languages ===" +# Example 2: Listing languages +echo "=== 2. Listing Languages ===" echo -echo "Include beta languages that are not yet stable:" -echo " deepl translate 'Hello world' --to ar --enable-beta-languages" -echo -echo "Beta languages may have lower quality but provide forward-compatibility" -echo "as DeepL adds support for new languages." -echo -echo "List available languages (including beta):" +echo "List available languages:" echo " deepl languages --source" echo " deepl languages --target" echo @@ -151,8 +145,7 @@ EOF echo "Combining multiple advanced flags:" echo " deepl translate complex.html --to de \\" echo " --tag-handling html --tag-handling-version v2 \\" -echo " --formality more --preserve-code \\" -echo " --enable-beta-languages" +echo " --formality more --preserve-code" echo deepl translate "$TEMP_DIR/complex.html" --to de --tag-handling html --tag-handling-version v2 --formality more --preserve-code --output "$TEMP_DIR/complex.de.html" echo @@ -164,10 +157,7 @@ echo "=== Advanced Options Summary ===" echo echo "Tag handling:" echo " --tag-handling Enable tag handling mode" -echo " --tag-handling-version Tag handling version (v2 = improved)" -echo -echo "Beta languages:" -echo " --enable-beta-languages Include unstable/beta languages" +echo " --tag-handling-version Tag handling version (v2 = improved, default)" echo echo "API endpoint:" echo " --api-url Custom API endpoint URL" diff --git a/examples/40-sync-tms-destination-trust.sh b/examples/40-sync-tms-destination-trust.sh new file mode 100755 index 00000000..576b3f53 --- /dev/null +++ b/examples/40-sync-tms-destination-trust.sh @@ -0,0 +1,150 @@ +#!/bin/bash +# Sync — TMS destination trust +# Demonstrates that a repo-supplied tms.server cannot redirect an +# environment-held TMS_API_KEY to a host you have not approved + +set -e # Exit on error + +echo "=== DeepL CLI: Sync — TMS Destination Trust ===" +echo + +# push talks to a TMS, not to DeepL, so no DeepL API key is needed. The TMS here +# is a throwaway listener on loopback, so this example needs no network either. +echo "No API key needed: push talks to a TMS, and this example runs its own." +echo + +PROJECT_DIR="/tmp/deepl-tms-trust-demo" +CONFIG_DIR="/tmp/deepl-tms-trust-config" +rm -rf "$PROJECT_DIR" "$CONFIG_DIR" +mkdir -p "$PROJECT_DIR/locales" "$CONFIG_DIR" + +LISTENER_PID="" +cleanup() { + [ -n "$LISTENER_PID" ] && kill "$LISTENER_PID" 2>/dev/null || true + rm -rf "$PROJECT_DIR" "$CONFIG_DIR" +} +trap cleanup EXIT + +# An isolated config dir, so the example cannot touch your real allowlist. +export DEEPL_CONFIG_DIR="$CONFIG_DIR" + +cd "$PROJECT_DIR" + +# 1. A stand-in TMS that records what it receives. Stands in for the attacker's +# listener in the refusal step and for a legitimate TMS in the last one. +cat > tms.mjs << 'EOF' +import http from 'node:http'; +const srv = http.createServer((req, res) => { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + console.error(` [tms] ${req.method} ${req.url}`); + console.error(` [tms] authorization: ${req.headers['authorization']}`); + console.error(` [tms] body: ${body}`); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + }); +}); +srv.listen(0, '127.0.0.1', () => + console.log(`PORT=${srv.address().port}`) +); +EOF + +node tms.mjs > port.txt 2> tms.log & +LISTENER_PID=$! +for _ in $(seq 1 50); do + grep -q PORT= port.txt 2>/dev/null && break + sleep 0.1 +done +PORT=$(sed 's/PORT=//' port.txt | tr -d '[:space:]') + +# 2. The checkout chooses the destination. In a hostile repository this is the +# attacker's host; the operator never typed it. +echo "1. A checkout whose .deepl-sync.yaml picks the TMS host" +cat > .deepl-sync.yaml << EOF +version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +tms: + enabled: true + server: http://localhost:$PORT + project_id: victim-project +EOF +cat .deepl-sync.yaml +echo + +echo '{ "greeting": "Hello" }' > locales/en.json +echo '{ "greeting": "Hallo" }' > locales/de.json + +# 3. The credential comes from the operator's environment, which is the +# recommended setup and normal for cross-project use. Nothing secret is in +# the repo, and that is the point: the repo only has to name the host. +echo "2. Push with an environment-supplied credential, non-interactively" +echo " (tms.allowedServers is empty, so this must not send anything)" +set +e +TMS_API_KEY='TMS-SECRET-CREDENTIAL-1234' deepl --no-input sync push +STATUS=$? +set -e +echo " exit code: $STATUS (7 = ConfigError)" +echo + +echo "3. What the stand-in TMS received" +if [ -s tms.log ]; then + cat tms.log +else + echo " (nothing — the credential never left the machine)" +fi +echo + +# 4. The approval lives in USER config, outside the repository, so it survives a +# fresh clone of the same project and does not travel with the repo. +echo "4. Approve the destination once, in your own configuration" +deepl config set tms.allowedServers localhost +deepl config get tms.allowedServers +echo +echo " Recorded in \$DEEPL_CONFIG_DIR/config.json, not in the checkout:" +grep -c allowedServers "$CONFIG_DIR/config.json" > /dev/null && \ + echo " ✓ $CONFIG_DIR/config.json" +test ! -f "$PROJECT_DIR/config.json" && echo " ✓ nothing written to $PROJECT_DIR" +echo + +# 5. Now the same command proceeds, and names the destination it used. +echo "5. Push again — approved, and the destination origin is reported" +TMS_API_KEY='TMS-SECRET-CREDENTIAL-1234' deepl --no-input sync push +echo +echo " What the TMS received:" +cat tms.log +echo + +# 6. Machine-readable output carries the destination too, so a pipeline can +# assert on it rather than trusting the count alone. +echo "6. JSON output includes the resolved destination" +TMS_API_KEY='TMS-SECRET-CREDENTIAL-1234' deepl --no-input sync push --format json +echo + +cat << 'EOF' + +Notes: + - The gate applies only to an ENVIRONMENT-supplied credential. A credential + inlined as tms.api_key / tms.token in .deepl-sync.yaml is not gated: it + belongs to the same file that chose the destination, so nothing of yours + leaks. Inlining a secret is still discouraged, and warned about separately. + - In an interactive terminal an unapproved host prompts once, naming the host + and what would be sent, and records a yes in your user config. This example + passes --no-input to show the non-interactive path, which fails closed. + - Matching is exact and case-insensitive on the parsed hostname, ignoring + scheme, port and path. A listed "example.com" does NOT approve + "tms.example.com", and there are no wildcards. + - Loopback is not exempt. This example approved "localhost" explicitly; a + co-tenant process listening on 127.0.0.1 is as much an exfiltration sink as + a remote host. + - See docs/SYNC.md#tms-destination-trust for the full contract. + +EOF + +echo "=== TMS destination trust example complete ===" diff --git a/examples/README.md b/examples/README.md index 3840266c..45f16e1d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -22,53 +22,63 @@ This directory contains practical, real-world examples of using the DeepL CLI. **Write:** - [Writing Enhancement](./13-write.sh) - Using DeepL Write API for grammar, style, and tone improvement -- [Write — Extended Languages](./36-write-extended-languages.sh) - JA/KO/ZH targets and tone/style on ES/IT/FR/PT variants -- [Correct](./37-correct.sh) - Spelling and grammar correction without rewording (`deepl correct`, alias `c`) +- [Write — Extended Languages](./14-write-extended-languages.sh) - JA/KO/ZH targets and tone/style on ES/IT/FR/PT variants +- [Correct](./15-correct.sh) - Spelling and grammar correction without rewording (`deepl correct`, alias `c`) **Voice:** -- [Voice Translation](./14-voice.sh) - Real-time speech translation via the Voice API +- [Voice Translation](./16-voice.sh) - Real-time speech translation via the Voice API ### Resources -- [Glossaries](./15-glossaries.sh) - Managing glossaries for consistent terminology -- [Translation Memory Listing](./33-tm-list.sh) — List translation memories on your account +- [Glossaries](./17-glossaries.sh) - Managing glossaries for consistent terminology +- [Translation Memory Listing](./18-tm-list.sh) — List translation memories on your account ### Workflow -- [Watch Mode](./16-watch-mode.sh) - Real-time file monitoring and auto-translation -- [Git Hooks Integration](./17-git-hooks.sh) - Automating translation validation in git workflow -- [CI/CD Integration](./18-cicd-integration.sh) - Using DeepL CLI in automated workflows -- [Sync — Basic Usage](./30-sync-basic.sh) - Scanning, diffing, and syncing i18n locale files -- [Sync — CI/CD Integration](./31-sync-ci.sh) - Using deepl sync in automated pipelines -- [Sync — Live Validation](./32-sync-live-validation.sh) - Comprehensive end-to-end validation of all sync features -- [Sync — Laravel PHP arrays](./34-sync-laravel-php.sh) - `laravel_php` format parser: AST allowlist, span-surgical reconstruct, pipe-pluralization warning gate, `sync.limits` caps +- [Watch Mode](./19-watch-mode.sh) - Real-time file monitoring and auto-translation +- [Git Hooks Integration](./20-git-hooks.sh) - Automating translation validation in git workflow +- [CI/CD Integration](./21-cicd-integration.sh) - Using DeepL CLI in automated workflows +- [Sync — Basic Usage](./22-sync-basic.sh) - Scanning, diffing, and syncing i18n locale files +- [Sync — CI/CD Integration](./23-sync-ci.sh) - Using deepl sync in automated pipelines +- [Sync — Live Validation](./24-sync-live-validation.sh) - Comprehensive end-to-end validation of all sync features +- [Sync — Laravel PHP arrays](./25-sync-laravel-php.sh) - `laravel_php` format parser: AST allowlist, span-surgical reconstruct, pipe-pluralization warning gate, `sync.limits` caps +- [Sync — Resolve Lockfile Conflicts](./26-sync-resolve.sh) - Auto-resolving a `.deepl-sync.lock` left conflicted by `git merge`, and previewing the decisions first +- [Sync — Audit Translation Consistency](./27-sync-audit.sh) - Finding one source term translated two different ways across a project +- [Sync — TMS Destination Trust](./40-sync-tms-destination-trust.sh) - Why a repo-supplied `tms.server` cannot redirect an environment-held `TMS_API_KEY`, and how `tms.allowedServers` approves one + +`deepl sync push` and `deepl sync pull` have no general example script here. They +need a reachable TMS endpoint and a `TMS_API_KEY`, so an example could not +exercise a real workflow unattended as part of `run-all.sh`; see +[docs/SYNC.md](../docs/SYNC.md) for their reference documentation. The destination-trust +example above is the exception — it runs its own throwaway TMS on loopback, so it +needs neither an API key nor a network. ### Configuration -- [Configuration](./19-configuration.sh) - Setting up and managing configuration -- [Custom Config Files](./20-custom-config-files.sh) - Using multiple configuration files for different projects -- [Cache Management](./21-cache.sh) - Working with the translation cache -- [Style Rules](./22-style-rules.sh) - Listing and using pre-configured style rules for consistent translations -- [Style Rules — CRUD](./35-style-rules-crud.sh) - End-to-end style-rules lifecycle: create, update rules, manage custom instructions, delete +- [Configuration](./28-configuration.sh) - Setting up and managing configuration +- [Custom Config Files](./29-custom-config-files.sh) - Using multiple configuration files for different projects +- [Cache Management](./30-cache.sh) - Working with the translation cache +- [Style Rules](./31-style-rules.sh) - Listing and using pre-configured style rules for consistent translations +- [Style Rules — CRUD](./32-style-rules-crud.sh) - End-to-end style-rules lifecycle: create, update rules, manage custom instructions, delete ### Information -- [Usage Monitoring](./23-usage-monitoring.sh) - Monitoring API character usage and quota -- [Supported Languages](./24-languages.sh) - Listing source and target languages supported by DeepL -- [Language Detection](./25-detect.sh) - Detecting the language of text input -- [Shell Completions](./26-completion.sh) - Setting up bash, zsh, and fish shell completions +- [Usage Monitoring](./33-usage-monitoring.sh) - Monitoring API character usage and quota +- [Supported Languages](./34-languages.sh) - Listing source and target languages supported by DeepL +- [Language Detection](./35-detect.sh) - Detecting the language of text input +- [Shell Completions](./36-completion.sh) - Setting up bash, zsh, and fish shell completions ### Administration -- [Admin API](./27-admin.sh) - Managing API keys and viewing organization usage analytics +- [Admin API](./37-admin.sh) - Managing API keys and viewing organization usage analytics ### Getting Started -- [Setup Wizard](./28-init.sh) - Interactive first-time setup with `deepl init` +- [Setup Wizard](./38-init.sh) - Interactive first-time setup with `deepl init` ### Advanced -- [Advanced Translation](./29-advanced-translate.sh) - Tag handling versions, beta languages, custom API URLs, and document minification +- [Advanced Translation](./39-advanced-translate.sh) - Tag handling versions, beta languages, custom API URLs, and document minification ## Prerequisites diff --git a/examples/run-all.sh b/examples/run-all.sh index 61f706c5..7d2e7829 100755 --- a/examples/run-all.sh +++ b/examples/run-all.sh @@ -62,43 +62,46 @@ EXAMPLES=( "12-cost-transparency.sh" # Core Commands - Write "13-write.sh" - "36-write-extended-languages.sh" - "37-correct.sh" + "14-write-extended-languages.sh" + "15-correct.sh" # Core Commands - Voice - "14-voice.sh" + "16-voice.sh" # Resources - "15-glossaries.sh" - "33-tm-list.sh" + "17-glossaries.sh" + "18-tm-list.sh" # Workflow - "16-watch-mode.sh" - "17-git-hooks.sh" - "18-cicd-integration.sh" - "30-sync-basic.sh" - "31-sync-ci.sh" - "32-sync-live-validation.sh" - "34-sync-laravel-php.sh" + "19-watch-mode.sh" + "20-git-hooks.sh" + "21-cicd-integration.sh" + "22-sync-basic.sh" + "23-sync-ci.sh" + "24-sync-live-validation.sh" + "25-sync-laravel-php.sh" + "26-sync-resolve.sh" + "27-sync-audit.sh" + "40-sync-tms-destination-trust.sh" # Configuration - "19-configuration.sh" - "20-custom-config-files.sh" - "21-cache.sh" - "22-style-rules.sh" - "35-style-rules-crud.sh" + "28-configuration.sh" + "29-custom-config-files.sh" + "30-cache.sh" + "31-style-rules.sh" + "32-style-rules-crud.sh" # Information - "23-usage-monitoring.sh" - "24-languages.sh" - "25-detect.sh" - "26-completion.sh" + "33-usage-monitoring.sh" + "34-languages.sh" + "35-detect.sh" + "36-completion.sh" # Administration - "27-admin.sh" + "37-admin.sh" # Getting Started - "28-init.sh" + "38-init.sh" # Advanced - "29-advanced-translate.sh" + "39-advanced-translate.sh" ) # Skip slow examples in fast mode if [ "$FAST_MODE" = true ]; then - echo "ℹ️ Fast mode enabled - skipping slow examples (16, 17)" + echo "ℹ️ Fast mode enabled - skipping slow examples (watch mode, git hooks)" echo EXAMPLES=( # Core Commands - Translate @@ -116,35 +119,38 @@ if [ "$FAST_MODE" = true ]; then "12-cost-transparency.sh" # Core Commands - Write "13-write.sh" - "36-write-extended-languages.sh" - "37-correct.sh" + "14-write-extended-languages.sh" + "15-correct.sh" # Core Commands - Voice - "14-voice.sh" + "16-voice.sh" # Resources - "15-glossaries.sh" + "17-glossaries.sh" # Workflow (watch/hooks skipped) - "18-cicd-integration.sh" - "30-sync-basic.sh" - "31-sync-ci.sh" - "32-sync-live-validation.sh" - "34-sync-laravel-php.sh" + "21-cicd-integration.sh" + "22-sync-basic.sh" + "23-sync-ci.sh" + "24-sync-live-validation.sh" + "25-sync-laravel-php.sh" + "26-sync-resolve.sh" + "27-sync-audit.sh" + "40-sync-tms-destination-trust.sh" # Configuration - "19-configuration.sh" - "20-custom-config-files.sh" - "21-cache.sh" - "22-style-rules.sh" - "35-style-rules-crud.sh" + "28-configuration.sh" + "29-custom-config-files.sh" + "30-cache.sh" + "31-style-rules.sh" + "32-style-rules-crud.sh" # Information - "23-usage-monitoring.sh" - "24-languages.sh" - "25-detect.sh" - "26-completion.sh" + "33-usage-monitoring.sh" + "34-languages.sh" + "35-detect.sh" + "36-completion.sh" # Administration - "27-admin.sh" + "37-admin.sh" # Getting Started - "28-init.sh" + "38-init.sh" # Advanced - "29-advanced-translate.sh" + "39-advanced-translate.sh" ) fi diff --git a/jest.config.js b/jest.config.js index 8a2c83ff..158dff48 100644 --- a/jest.config.js +++ b/jest.config.js @@ -56,6 +56,11 @@ export default { transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: { + // tsconfig.json disables source maps to keep them out of the published + // package. ts-jest merges the options below over that file rather than + // replacing it, and istanbul needs the maps to attribute coverage to + // TypeScript lines instead of to positions in the emitted JavaScript. + sourceMap: true, // Relaxed settings for tests strict: true, esModuleInterop: true, @@ -93,18 +98,18 @@ export default { resetMocks: true, restoreMocks: true, - // KNOWN BENIGN WARNING: jest emits "A worker process has failed to exit - // gracefully" at the end of every full-suite run. Stack traces lead to six - // `nock(...).replyWithError(...)` test sites in deepl-client.test.ts and - // three integration files. The leak is in nock v14 + @mswjs/interceptors: - // each replyWithError call constructs a synthetic Node IncomingMessage - // that is never drained, leaving an HTTPINCOMINGMESSAGE handle pinned in - // the worker. The affected tests all PASS — only the orphaned handles - // trigger the warning. `forceExit: true` does not suppress it (the warning - // fires from the worker, not the main process, before forceExit applies), - // and `--runInBand` eliminates it but is 5× slower. Fixing upstream - // (nock/mswjs) is out of scope. Run `npm run test:debug` to audit for - // any NEW leak source beyond the six known replyWithError sites. + // KNOWN BENIGN WARNING: every full-suite run ends with "A worker process has + // failed to exit gracefully". nock's interceptor (@mswjs/interceptors) leaves + // undrained IncomingMessage objects behind, each pinned as an + // HTTPINCOMINGMESSAGE handle the worker cannot shed. All tests still pass. + // + // Nothing available here suppresses it: the teardown in tests/setup.ts and + // HttpClient.destroy() never own that socket, `forceExit` fires too late, and + // `--runInBand` avoids it at ~5x wall clock. Going past interceptors 0.41 is + // untested — 0.42 is ESM-only and fails the CJS transform. + // + // Audit real leaks with `npm run test:debug` by handle type, not count: + // anything but HTTPINCOMINGMESSAGE from @mswjs/interceptors is new. // Verbose output verbose: true, diff --git a/package-lock.json b/package-lock.json index 5130390d..9cf6a7da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@deepl/cli", - "version": "1.2.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@deepl/cli", - "version": "1.2.0", + "version": "2.0.0", "license": "MIT", "dependencies": { "@inquirer/prompts": "^8.5.2", @@ -3952,9 +3952,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -4341,13 +4341,6 @@ "node": ">=22.12.0" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/conventional-changelog-angular": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-9.2.1.tgz", @@ -5445,23 +5438,6 @@ "tslib": "2" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/glob/node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -8550,24 +8526,6 @@ "node": ">=8" } }, - "node_modules/test-exclude/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/test-exclude/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", diff --git a/package.json b/package.json index 00144f7a..31696646 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepl/cli", - "version": "1.2.0", + "version": "2.0.0", "publishConfig": { "access": "public" }, @@ -19,6 +19,7 @@ "!dist/**/*.tsbuildinfo", "!dist/**/*.js.map", "!dist/**/*.d.ts.map", + "docs", "README.md", "LICENSE", "SECURITY.md" @@ -39,13 +40,16 @@ "test:debug": "jest --runInBand --detectOpenHandles --forceExit=false", "examples": "bash examples/run-all.sh", "examples:fast": "bash examples/run-all.sh --fast", - "lint": "eslint 'src/**/*.ts' 'tests/**/*.ts'", + "lint": "eslint 'src/**/*.ts' 'tests/**/*.ts' --max-warnings 0", "lint:fix": "eslint 'src/**/*.ts' 'tests/**/*.ts' --fix", "type-check": "tsc --noEmit", "check-deps": "node scripts/check-dependencies.mjs", "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"", - "prepublishOnly": "npm run build" + "format:staged": "node scripts/format-staged.mjs", + "prepublishOnly": "npm run build", + "generate:languages": "node scripts/generate-language-registry.mjs", + "check:languages": "node scripts/generate-language-registry.mjs --check" }, "keywords": [ "deepl", @@ -55,14 +59,23 @@ "localization", "translation-tool", "command-line", - "typescript" + "typescript", + "deepl-write", + "glossary", + "translation-memory", + "gettext", + "xliff", + "continuous-localization" ], "author": "DeepL SE", "license": "MIT", "engines": { - "node": ">=24.0.0", + "node": ">=24.15.0", "npm": ">=9.0.0" }, + "overrides": { + "brace-expansion": "^5.0.9" + }, "dependencies": { "@inquirer/prompts": "^8.5.2", "axios": "^1.7.9", @@ -105,7 +118,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/DeepL/deepl-cli" + "url": "git+https://github.com/DeepL/deepl-cli.git" }, "bugs": { "url": "https://github.com/DeepL/deepl-cli/issues" diff --git a/scripts/format-staged.mjs b/scripts/format-staged.mjs new file mode 100644 index 00000000..02408239 --- /dev/null +++ b/scripts/format-staged.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +/** + * Formats staged TypeScript files with Prettier and re-stages them, so + * formatting is applied rather than asked of the author and `format:check` + * cannot fail in CI for a commit made through this hook. + * + * Prettier is run twice. It preserves whether an object literal was written + * multi-line, and that interacts with member-chain breaking: an over-long + * single-line object at the end of a chain formats to "chain broken, object + * expanded" on the first pass, then to "chain collapsed, object hugged" on the + * second, which is the fixed point. One pass would leave content a later + * `format:check` rejects. + */ + +import { execFileSync } from 'node:child_process'; + +const PREFIXES = ['src/', 'tests/']; + +function git(args) { + return execFileSync('git', args, { encoding: 'utf-8' }); +} + +function paths(args) { + return new Set( + git(args) + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + ); +} + +const staged = [...paths(['diff', '--cached', '--name-only', '--diff-filter=ACM'])] + .filter((file) => file.endsWith('.ts')) + .filter((file) => PREFIXES.some((prefix) => file.startsWith(prefix))); + +// Rewriting a file that is only partially staged and then re-adding it would +// stage the hunks the author deliberately left out, so those are reported and +// left alone rather than quietly widened. +const unstaged = paths(['diff', '--name-only', '--diff-filter=ACM']); +const partial = staged.filter((file) => unstaged.has(file)); +const safe = staged.filter((file) => !unstaged.has(file)); + +if (partial.length > 0) { + console.warn( + `prettier: skipping ${partial.length} partially staged file(s); run "npm run format" before committing:\n` + + partial.map((file) => ` ${file}`).join('\n') + ); +} + +if (safe.length === 0) { + process.exit(0); +} + +// Respects .prettierignore, so an ignored file — the generated language +// snapshot — is skipped rather than rewritten into permanent check:languages +// drift. +for (let pass = 0; pass < 2; pass++) { + execFileSync('npx', ['prettier', '--write', '--ignore-unknown', ...safe], { + stdio: 'inherit', + }); +} + +git(['add', '--', ...safe]); diff --git a/scripts/generate-language-registry.mjs b/scripts/generate-language-registry.mjs new file mode 100644 index 00000000..c1f26b13 --- /dev/null +++ b/scripts/generate-language-registry.mjs @@ -0,0 +1,285 @@ +#!/usr/bin/env node +/** + * Regenerates src/data/language-entries.ts from GET /v3/languages, keeping the + * bundled snapshot a build artifact of the API rather than a hand-kept list. + * + * Tiers are derived, not judged: the derivation lives in + * src/data/language-registry.ts and is imported from dist/ so the snapshot and + * the runtime fallback cannot disagree. + * + * Usage: + * node scripts/generate-language-registry.mjs # rewrite the file + * node scripts/generate-language-registry.mjs --check # exit 1 on drift + * + * Needs DEEPL_API_KEY and a current build (npm run build). + */ +import { readFileSync, writeFileSync, existsSync, realpathSync } from 'node:fs'; +import * as path from 'node:path'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const TARGET = path.join(ROOT, 'src', 'data', 'language-entries.ts'); +const DERIVATION = path.join(ROOT, 'dist', 'data', 'language-registry.js'); + +/** + * A core count below this means the derivation is broken rather than that DeepL + * dropped languages -- most likely the features matrix no longer reports + * `glossary`, which would tier every language as extended and make --formality + * and --glossary unusable. Cheap floor over the whole failure class. + */ +const MIN_CORE_LANGUAGES = 20; + +const GROUPS = [ + ['core', 'Core languages (full feature support: formality, glossary, all model types)'], + ['regional', 'Regional variants (target-only)'], + ['extended', 'Extended languages (quality_optimized only, no formality/glossary)'], +]; + +function fail(message) { + console.error(`error: ${message}`); + process.exit(1); +} + +const byCode = (a, b) => a.code.localeCompare(b.code, 'en'); + +const LANGUAGE_CODE = /^[a-z]{2,3}(-[a-z0-9]{2,4})?$/; +/** Letters, marks, digits and the punctuation DeepL's display names actually use. */ +const DISPLAY_NAME = /^[\p{L}\p{M}\p{N} ()'’.,-]{1,60}$/u; + +/** + * Quote a value as a single-quoted TypeScript string literal, escaping what + * would otherwise end the literal or the line. + */ +function quote(value) { + const escaped = String(value) + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); + return `'${escaped}'`; +} + +/** + * Rejects a response field that has no business being in a language list. + * The output of this script is TypeScript that the next build compiles and the + * test suite imports, so a response field is untrusted input to a code + * generator: it is validated, not merely escaped. + */ +export function assertRenderable(entry) { + if (typeof entry.code !== 'string' || !LANGUAGE_CODE.test(entry.code)) { + throw new Error(`refusing to write language code ${JSON.stringify(entry.code)}: not shaped like a language tag`); + } + if (typeof entry.name !== 'string' || !DISPLAY_NAME.test(entry.name)) { + throw new Error(`refusing to write display name ${JSON.stringify(entry.name)} for ${entry.code}`); + } + if (!['core', 'regional', 'extended'].includes(entry.category)) { + throw new Error(`unexpected category ${JSON.stringify(entry.category)} for ${entry.code}`); + } +} + +function renderEntry(entry) { + const fields = [`code: ${quote(entry.code)}`, `name: ${quote(entry.name)}`]; + fields.push(`category: ${quote(entry.category)}`); + if (entry.targetOnly) fields.push('targetOnly: true'); + return ` { ${fields.join(', ')} },`; +} + +/** + * Renders the whole file. Exported so the snapshot can also be re-rendered from + * the data it already holds, without a live API call. + */ +export function renderRegistry(entries, writeTargets) { + // Validated before grouping: grouping filters by category, so an entry with an + // unrecognized one would be dropped from the output without ever being checked. + entries.forEach(assertRenderable); + + const body = GROUPS.map(([category, heading]) => { + const group = entries.filter(e => e.category === category).sort(byCode); + return [` // ${heading}`, ...group.map(renderEntry)].join('\n'); + }).join('\n\n'); + + return `/** + * Supported DeepL languages, generated from GET /v3/languages. + * + * DO NOT EDIT BY HAND. Run "npm run generate:languages" to refresh, and + * "npm run check:languages" to detect drift. Tiers are derived by + * deriveLanguageEntry in ./language-registry.ts, not chosen here. + * + * The API is the authority on which languages exist; this snapshot exists so + * the CLI can list and validate languages without a network call or API key. + * It may therefore lag the API, which is why callers accept well-formed codes + * it does not contain rather than rejecting them. + * + * \`as const\` is load-bearing: the Language union in src/types/common.ts is + * derived from these codes, so a language added upstream widens the type on + * regenerate. + */ +import type { LanguageEntry } from './language-registry.js'; + +export const ENTRIES = [ +${body} +] as const satisfies readonly LanguageEntry[]; + +/** + * Target languages the Write API accepts, from resource=write. + * + * \`write\` and \`correct\` check a code against this list locally, because the + * set is small enough for the error to name every option. A code shaped like a + * language tag but absent from the list still goes to the API, with the list as + * a warning, so a language added upstream is usable before a regenerate. + * + * \`as const\` is load-bearing -- the WriteLanguage union in src/types/api.ts is + * derived from it, so a language added upstream widens the type on regenerate. + */ +export const WRITE_TARGET_LANGUAGES = [ +${writeTargets.map(code => ` ${quote(code)},`).join('\n')} +] as const; +`; +} + +async function main() { + const checkOnly = process.argv.includes('--check'); + + const apiKey = process.env['DEEPL_API_KEY']; + if (!apiKey) { + fail('DEEPL_API_KEY is not set; the snapshot can only be generated from the live API.'); + } + if (!existsSync(DERIVATION)) { + fail(`missing ${path.relative(ROOT, DERIVATION)}; run "npm run build" first.`); + } + + const { deriveLanguageEntry } = await import(DERIVATION); + + const host = apiKey.endsWith(':fx') ? 'https://api-free.deepl.com' : 'https://api.deepl.com'; + + async function fetchResource(resource) { + const response = await fetch(`${host}/v3/languages?resource=${resource}`, { + headers: { Authorization: `DeepL-Auth-Key ${apiKey}` }, + }); + if (!response.ok) { + return { + resource, + error: `GET /v3/languages?resource=${resource} returned ${response.status} ${response.statusText}`, + }; + } + const languages = await response.json(); + if (!Array.isArray(languages) || languages.length === 0) { + return { resource, error: `GET /v3/languages?resource=${resource} returned no languages` }; + } + return { resource, languages }; + } + + // Fetched and reported together, so a key that cannot read one resource still + // regenerates from the other and both failures surface at once. + const [translateResult, writeResult] = await Promise.all([ + fetchResource('translate_text'), + fetchResource('write'), + ]); + const errors = [translateResult, writeResult].filter(r => r.error).map(r => r.error); + if (errors.length > 0) { + fail(errors.join('\n ')); + } + + const entries = translateResult.languages.map(deriveLanguageEntry); + // The write endpoints take a target language only, so the list is filtered by + // that role rather than run through deriveLanguageEntry -- write has no notion + // of the core/regional/extended tiers. + const writeTargets = writeResult.languages + .filter(language => language.usable_as_target) + .map(language => language.lang.toLowerCase()) + .sort((a, b) => a.localeCompare(b, 'en')); + + const coreCount = entries.filter(e => e.category === 'core').length; + if (coreCount < MIN_CORE_LANGUAGES) { + fail( + `only ${coreCount} core languages derived (expected at least ${MIN_CORE_LANGUAGES}); ` + + 'the features matrix probably stopped reporting "glossary". Refusing to write a ' + + 'snapshot that would retier every language as extended.', + ); + } + // An empty write list collapses the WriteLanguage union to never, which would + // reject every --lang while naming no valid option at all. + if (writeTargets.length === 0) { + fail('no write target languages reported (expected usable_as_target on resource=write)'); + } + for (const code of writeTargets) { + if (!LANGUAGE_CODE.test(code)) { + fail(`refusing to write Write language code ${JSON.stringify(code)}: not shaped like a language tag`); + } + } + + let contents; + try { + contents = renderRegistry(entries, writeTargets); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } + + if (checkOnly) { + const current = existsSync(TARGET) ? readFileSync(TARGET, 'utf8') : ''; + if (current === contents) { + console.log( + `${entries.length} languages, ${writeTargets.length} write targets; snapshot is current.`, + ); + process.exit(0); + } + // Name which list moved: the two come from different resources, so "N + // languages upstream" is misleading when it is the write list that drifted. + // Whole blocks are compared, not just the codes, so a renamed display name + // is reported as real drift. + const blockIn = (source, open, close) => { + const start = source.indexOf(open); + if (start === -1) return ''; + const from = start + open.length; + const end = source.indexOf(close, from); + return source.slice(from, end === -1 ? undefined : end); + }; + const blocks = [ + ['translate_text', `${entries.length}`, 'export const ENTRIES', '\n] as const satisfies'], + ['write', `${writeTargets.length}`, 'export const WRITE_TARGET_LANGUAGES', '] as const;'], + ]; + const drifted = blocks + .filter(([, , open, close]) => blockIn(current, open, close) !== blockIn(contents, open, close)) + .map(([name, count]) => `${name} (${count} upstream)`); + const detail = drifted.length > 0 ? drifted.join(', ') : 'file header or formatting'; + console.error( + `error: ${path.relative(ROOT, TARGET)} is out of date with the API -- ${detail}.\n` + + 'Run: npm run generate:languages', + ); + process.exit(1); + } + + writeFileSync(TARGET, contents); + const counts = GROUPS.map(([c]) => `${c} ${entries.filter(e => e.category === c).length}`); + console.log( + `wrote ${path.relative(ROOT, TARGET)}: ${entries.length} languages (${counts.join(', ')}), ` + + `${writeTargets.length} write targets`, + ); +} + +// Importable for re-rendering without touching the network; only the CLI entry +// point fetches. argv[1] is compared through realpathSync because Node resolves +// the ESM entry to its real path, so a symlinked checkout (or an npm-linked +// package) would otherwise make both npm scripts silent no-ops. +const invokedPath = process.argv[1]; +const invokedDirectly = + invokedPath !== undefined && + (() => { + try { + return realpathSync(invokedPath) === import.meta.filename; + } catch { + return invokedPath === import.meta.filename; + } + })(); + +if (invokedDirectly) { + // Caught so a thrown fetch, or an HTML error body that does not parse, reports + // itself the way every other failure in this script does rather than as an + // unhandled rejection with a stack trace. + try { + await main(); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } +} diff --git a/src/api/admin-client.ts b/src/api/admin-client.ts index bfe8550e..64852c9e 100644 --- a/src/api/admin-client.ts +++ b/src/api/admin-client.ts @@ -1,5 +1,10 @@ import { HttpClient, DeepLClientOptions } from './http-client.js'; -import { AdminApiKey, AdminUsageOptions, AdminUsageReport, UsageBreakdown } from '../types/index.js'; +import { + AdminApiKey, + AdminUsageOptions, + AdminUsageReport, + UsageBreakdown, +} from '../types/index.js'; import { AuthError } from '../utils/errors.js'; export class AdminClient extends HttpClient { @@ -15,26 +20,31 @@ export class AdminClient extends HttpClient { protected override handleError( error: unknown, context?: string, - traceId?: string, + traceId?: string ): Error { const result = super.handleError(error, context, traceId); if (result instanceof AuthError) { return new AuthError( result.message, - 'The admin API requires an administrator API key; a valid regular API key is not sufficient. Use a key created by your DeepL account administrator.', + 'The admin API requires an administrator API key; a valid regular API key is not sufficient. Use a key created by your DeepL account administrator.' ); } return result; } async listApiKeys(): Promise { - const response = await this.makeJsonRequest>('GET', '/v2/admin/developer-keys'); + const response = await this.makeJsonRequest< + Array<{ + key_id: string; + label: string; + creation_time: string; + is_deactivated: boolean; + usage_limits?: { + characters?: number | null; + speech_to_text_milliseconds?: number | null; + }; + }> + >('GET', '/v2/admin/developer-keys'); return response.map((key) => this.normalizeApiKey(key)); } @@ -51,7 +61,10 @@ export class AdminClient extends HttpClient { label: string; creation_time: string; is_deactivated: boolean; - usage_limits?: { characters?: number | null; speech_to_text_milliseconds?: number | null }; + usage_limits?: { + characters?: number | null; + speech_to_text_milliseconds?: number | null; + }; }>('POST', '/v2/admin/developer-keys', body); return this.normalizeApiKey(response); @@ -59,27 +72,31 @@ export class AdminClient extends HttpClient { async deactivateApiKey(keyId: string): Promise { await this.makeJsonRequest( - 'PUT', '/v2/admin/developer-keys/deactivate', { key_id: keyId } + 'PUT', + '/v2/admin/developer-keys/deactivate', + { key_id: keyId } ); } async renameApiKey(keyId: string, label: string): Promise { - await this.makeJsonRequest( - 'PUT', '/v2/admin/developer-keys/label', { key_id: keyId, label } - ); + await this.makeJsonRequest('PUT', '/v2/admin/developer-keys/label', { + key_id: keyId, + label, + }); } async setApiKeyLimit( keyId: string, characters: number | null, - speechToTextMilliseconds?: number | null, + speechToTextMilliseconds?: number | null ): Promise { const body: Record = { key_id: keyId, characters }; if (speechToTextMilliseconds !== undefined) { body['speech_to_text_milliseconds'] = speechToTextMilliseconds; } await this.makeJsonRequest( - 'PUT', '/v2/admin/developer-keys/limits', + 'PUT', + '/v2/admin/developer-keys/limits', body ); } @@ -152,7 +169,10 @@ export class AdminClient extends HttpClient { label: string; creation_time: string; is_deactivated: boolean; - usage_limits?: { characters?: number | null; speech_to_text_milliseconds?: number | null }; + usage_limits?: { + characters?: number | null; + speech_to_text_milliseconds?: number | null; + }; }): AdminApiKey { const result: AdminApiKey = { keyId: key.key_id, @@ -164,7 +184,8 @@ export class AdminClient extends HttpClient { result.usageLimits = { characters: key.usage_limits.characters, ...(key.usage_limits.speech_to_text_milliseconds !== undefined && { - speechToTextMilliseconds: key.usage_limits.speech_to_text_milliseconds, + speechToTextMilliseconds: + key.usage_limits.speech_to_text_milliseconds, }), }; } diff --git a/src/api/deepl-client.ts b/src/api/deepl-client.ts index 90a3f784..c1d640da 100644 --- a/src/api/deepl-client.ts +++ b/src/api/deepl-client.ts @@ -1,5 +1,18 @@ -import { HttpClient, DeepLClientOptions } from './http-client.js'; -import { TranslationClient, TranslationResult, isTranslationResult, ProductUsage, UsageInfo, LanguageInfo } from './translation-client.js'; +import { + HttpClient, + DeepLClientOptions, + resolveClientBaseUrl, +} from './http-client.js'; +import { + TranslationClient, + TranslationResult, + isTranslationResult, + ProductUsage, + UsageInfo, + LanguageInfo, + LanguageFeature, + LanguageFeatures, +} from './translation-client.js'; import { GlossaryClient } from './glossary-client.js'; import { DocumentClient } from './document-client.js'; import { WriteClient } from './write-client.js'; @@ -31,7 +44,15 @@ import { AdminUsageReport, } from '../types/index.js'; -export { TranslationResult, isTranslationResult, ProductUsage, UsageInfo, LanguageInfo }; +export { + TranslationResult, + isTranslationResult, + ProductUsage, + UsageInfo, + LanguageInfo, + LanguageFeature, + LanguageFeatures, +}; export class DeepLClient { private readonly apiKey: string; @@ -50,8 +71,20 @@ export class DeepLClient { this.options = options; } + /** + * The base URL requests from this client will actually go to. Cache keys + * must include it: the same request against a different endpoint is a + * different request, and every endpoint shares one cache DB. + */ + get resolvedBaseUrl(): string { + return resolveClientBaseUrl(this.options); + } + private get translationClient(): TranslationClient { - this._translationClient ??= new TranslationClient(this.apiKey, this.options); + this._translationClient ??= new TranslationClient( + this.apiKey, + this.options + ); return this._translationClient; } @@ -131,7 +164,12 @@ export class DeepLClient { targetLangs: Language[], entries: string ): Promise { - return this.glossaryClient.createGlossary(name, sourceLang, targetLangs, entries); + return this.glossaryClient.createGlossary( + name, + sourceLang, + targetLangs, + entries + ); } async listGlossaries(): Promise { @@ -151,7 +189,11 @@ export class DeepLClient { sourceLang: Language, targetLang: Language ): Promise { - return this.glossaryClient.getGlossaryEntries(glossaryId, sourceLang, targetLang); + return this.glossaryClient.getGlossaryEntries( + glossaryId, + sourceLang, + targetLang + ); } async updateGlossaryEntries( @@ -160,7 +202,12 @@ export class DeepLClient { targetLang: Language, entries: string ): Promise { - return this.glossaryClient.updateGlossaryEntries(glossaryId, sourceLang, targetLang, entries); + return this.glossaryClient.updateGlossaryEntries( + glossaryId, + sourceLang, + targetLang, + entries + ); } async replaceGlossaryDictionary( @@ -169,7 +216,12 @@ export class DeepLClient { targetLang: Language, entries: string ): Promise { - return this.glossaryClient.replaceGlossaryDictionary(glossaryId, sourceLang, targetLang, entries); + return this.glossaryClient.replaceGlossaryDictionary( + glossaryId, + sourceLang, + targetLang, + entries + ); } async updateGlossary( @@ -196,7 +248,11 @@ export class DeepLClient { sourceLang: Language, targetLang: Language ): Promise { - return this.glossaryClient.deleteGlossaryDictionary(glossaryId, sourceLang, targetLang); + return this.glossaryClient.deleteGlossaryDictionary( + glossaryId, + sourceLang, + targetLang + ); } async uploadDocument( @@ -238,11 +294,17 @@ export class DeepLClient { return this.styleRulesClient.createStyleRule(options); } - async getStyleRule(styleId: string, detailed = false): Promise { + async getStyleRule( + styleId: string, + detailed = false + ): Promise { return this.styleRulesClient.getStyleRule(styleId, detailed); } - async updateStyleRule(styleId: string, options: UpdateStyleRuleOptions): Promise { + async updateStyleRule( + styleId: string, + options: UpdateStyleRuleOptions + ): Promise { return this.styleRulesClient.updateStyleRule(styleId, options); } @@ -250,27 +312,37 @@ export class DeepLClient { return this.styleRulesClient.deleteStyleRule(styleId); } - async replaceConfiguredRules(styleId: string, rules: ConfiguredRules): Promise { + async replaceConfiguredRules( + styleId: string, + rules: ConfiguredRules + ): Promise { return this.styleRulesClient.replaceConfiguredRules(styleId, rules); } async createCustomInstruction( styleId: string, - options: CreateCustomInstructionOptions, + options: CreateCustomInstructionOptions ): Promise { return this.styleRulesClient.createCustomInstruction(styleId, options); } - async getCustomInstruction(styleId: string, label: string): Promise { + async getCustomInstruction( + styleId: string, + label: string + ): Promise { return this.styleRulesClient.getCustomInstruction(styleId, label); } async updateCustomInstruction( styleId: string, label: string, - options: UpdateCustomInstructionOptions, + options: UpdateCustomInstructionOptions ): Promise { - return this.styleRulesClient.updateCustomInstruction(styleId, label, options); + return this.styleRulesClient.updateCustomInstruction( + styleId, + label, + options + ); } async deleteCustomInstruction(styleId: string, label: string): Promise { @@ -293,7 +365,10 @@ export class DeepLClient { return this.adminClient.renameApiKey(keyId, label); } - async setApiKeyLimit(keyId: string, characters: number | null): Promise { + async setApiKeyLimit( + keyId: string, + characters: number | null + ): Promise { return this.adminClient.setApiKeyLimit(keyId, characters); } diff --git a/src/api/document-client.ts b/src/api/document-client.ts index 6849094b..e0a0b921 100644 --- a/src/api/document-client.ts +++ b/src/api/document-client.ts @@ -1,7 +1,19 @@ -import { HttpClient, DeepLClientOptions } from './http-client.js'; -import { DocumentTranslationOptions, DocumentHandle, DocumentStatus } from '../types/index.js'; -import { ValidationError } from '../utils/errors.js'; +import { + HttpClient, + DeepLClientOptions, + MAX_TRANSFER_BYTES, +} from './http-client.js'; +import { + DocumentTranslationOptions, + DocumentHandle, + DocumentStatus, +} from '../types/index.js'; +import { NetworkError, ValidationError } from '../utils/errors.js'; import { normalizeFormality } from '../utils/formality.js'; +import { + resolveGlossaryWireParams, + encodeGlossaryIdsForMultipart, +} from '../utils/glossary-params.js'; interface DeepLDocumentUploadResponse { document_id: string; @@ -20,6 +32,35 @@ interface DeepLDocumentStatusResponse { * than the interactive request timeout. */ const TRANSFER_TIMEOUT_MS = 300_000; +/** + * A document ID is opaque to this client and goes straight into a URL path, so + * only characters that cannot change the path's structure are allowed. DeepL + * returns uppercase hex; the wider alphabet leaves room for a format change + * without leaving room for `..`, `/`, `%`, `?` or `#`. + */ +const DOCUMENT_ID_RE = /^[A-Za-z0-9_-]+$/; + +/** + * The document ID reaching `/v2/document/{id}` comes from the upload response, + * which is untrusted: `--api-url`, `api.baseUrl` or a proxy can redirect the + * endpoint, and a redirected host that answers with + * `document_id: '../../v3/glossaries'` steers the client's own follow-up + * requests onto a different route. Checked at each interpolation site rather + * than once on the upload response, so a handle assembled anywhere else is + * covered too. + * + * `NetworkError` rather than `ValidationError`: nothing the user typed is + * wrong. That is also why this reads differently from + * `GlossaryClient.validateGlossaryId`, which guards an ID the user supplied. + */ +function assertUsableDocumentId(documentId: string): void { + if (typeof documentId === 'string' && DOCUMENT_ID_RE.test(documentId)) return; + throw new NetworkError( + `Unexpected API response: document_id must be a document identifier, got ${JSON.stringify(documentId)}. The endpoint that returned it can redirect this client's own requests.`, + 'Check the endpoint in use (--api-url, api.baseUrl, or a proxy). No further document request was sent.' + ); +} + export class DocumentClient extends HttpClient { constructor(apiKey: string, options: DeepLClientOptions = {}) { super(apiKey, options); @@ -38,7 +79,9 @@ export class DocumentClient extends HttpClient { } if (!options.filename) { - throw new ValidationError('filename is required when uploading document as Buffer'); + throw new ValidationError( + 'filename is required when uploading document as Buffer' + ); } const { default: FormData } = await import('form-data'); @@ -50,16 +93,33 @@ export class DocumentClient extends HttpClient { () => { const formData = new FormData(); formData.append('file', file, options.filename); - formData.append('target_lang', this.normalizeLanguage(options.targetLang).toUpperCase()); + formData.append( + 'target_lang', + this.normalizeLanguage(options.targetLang).toUpperCase() + ); if (options.sourceLang) { - formData.append('source_lang', this.normalizeLanguage(options.sourceLang).toUpperCase()); + formData.append( + 'source_lang', + this.normalizeLanguage(options.sourceLang).toUpperCase() + ); } if (options.formality) { - formData.append('formality', normalizeFormality(options.formality, 'text')); + formData.append( + 'formality', + normalizeFormality(options.formality, 'text') + ); } - if (options.glossaryId) { - formData.append('glossary_id', options.glossaryId); + const glossaryParams = resolveGlossaryWireParams(options); + if (glossaryParams) { + if ('glossary_id' in glossaryParams) { + formData.append('glossary_id', glossaryParams.glossary_id); + } else { + formData.append( + 'glossary_ids', + encodeGlossaryIdsForMultipart(glossaryParams.glossary_ids) + ); + } } if (options.outputFormat) { formData.append('output_format', options.outputFormat); @@ -67,9 +127,6 @@ export class DocumentClient extends HttpClient { if (options.enableDocumentMinification) { formData.append('enable_document_minification', '1'); } - if (options.enableBetaLanguages) { - formData.append('enable_beta_languages', '1'); - } return { data: formData, @@ -91,6 +148,7 @@ export class DocumentClient extends HttpClient { } async getDocumentStatus(handle: DocumentHandle): Promise { + assertUsableDocumentId(handle.documentId); try { const response = await this.makeRequest( 'POST', @@ -116,6 +174,7 @@ export class DocumentClient extends HttpClient { * permanently, so this request is never retried. */ async downloadDocument(handle: DocumentHandle): Promise { + assertUsableDocumentId(handle.documentId); try { const response = await this.makeRawRequest( 'POST', @@ -131,7 +190,11 @@ export class DocumentClient extends HttpClient { responseType: 'arraybuffer', }; }, - { maxRetries: 0, timeout: this.transferTimeout } + { + maxRetries: 0, + timeout: this.transferTimeout, + maxContentLength: MAX_TRANSFER_BYTES, + } ); return Buffer.from(response); diff --git a/src/api/glossary-client.ts b/src/api/glossary-client.ts index 323d9862..2aed47b8 100644 --- a/src/api/glossary-client.ts +++ b/src/api/glossary-client.ts @@ -1,12 +1,17 @@ import { HttpClient, DeepLClientOptions } from './http-client.js'; -import { Language, GlossaryInfo, GlossaryLanguagePair, normalizeGlossaryInfo, GlossaryApiResponse } from '../types/index.js'; +import { + Language, + GlossaryInfo, + GlossaryLanguagePair, + normalizeGlossaryInfo, + GlossaryApiResponse, +} from '../types/index.js'; import { ValidationError } from '../utils/errors.js'; -interface DeepLGlossaryLanguagePairsResponse { - supported_languages: Array<{ - source_lang: string; - target_lang: string; - }>; +interface DeepLV3GlossaryLanguageResponse { + lang: string; + usable_as_source?: boolean; + usable_as_target?: boolean; } export class GlossaryClient extends HttpClient { @@ -14,16 +19,39 @@ export class GlossaryClient extends HttpClient { super(apiKey, options); } + /** + * Lists glossary language pairs via GET /v3/languages?resource=glossary + * (the v2 pairs endpoint is deprecated). v3 returns one role-flagged + * language list instead of pairs; the source×target cross-product minus + * identity reproduces the v2 pair set exactly (verified live: 992 pairs, + * zero difference in either direction). + */ async getGlossaryLanguages(): Promise { - const response = await this.makeRequest( + const response = await this.makeRequest( 'GET', - '/v2/glossary-language-pairs' + '/v3/languages', + { resource: 'glossary' } ); - return response.supported_languages.map((pair) => ({ - sourceLang: this.normalizeLanguage(pair.source_lang), - targetLang: this.normalizeLanguage(pair.target_lang), - })); + // `!== false`, not truthiness: an absent flag is not a denial, and the + // language registry reads it the same way. + const sources = response.filter((lang) => lang.usable_as_source !== false); + const targets = response.filter((lang) => lang.usable_as_target !== false); + + const pairs: GlossaryLanguagePair[] = []; + for (const source of sources) { + for (const target of targets) { + // Compared after normalization, so casing differing between the two role + // lists cannot emit a self-pair the API does not offer. + const sourceLang = this.normalizeLanguage(source.lang); + const targetLang = this.normalizeLanguage(target.lang); + if (sourceLang === targetLang) { + continue; + } + pairs.push({ sourceLang, targetLang }); + } + } + return pairs; } async createGlossary( @@ -36,7 +64,7 @@ export class GlossaryClient extends HttpClient { throw new ValidationError('At least one target language is required'); } - const dictionaries = targetLangs.map(targetLang => ({ + const dictionaries = targetLangs.map((targetLang) => ({ source_lang: sourceLang.toUpperCase(), target_lang: targetLang.toUpperCase(), entries, @@ -44,7 +72,8 @@ export class GlossaryClient extends HttpClient { })); const response = await this.makeJsonRequest( - 'POST', '/v3/glossaries', + 'POST', + '/v3/glossaries', { name, dictionaries } ); @@ -53,13 +82,12 @@ export class GlossaryClient extends HttpClient { async listGlossaries(): Promise { try { - const response = await this.makeRequest<{ glossaries: GlossaryApiResponse[] }>( - 'GET', - '/v3/glossaries' - ); + const response = await this.makeRequest<{ + glossaries: GlossaryApiResponse[]; + }>('GET', '/v3/glossaries'); return (response.glossaries || []).map((g) => - normalizeGlossaryInfo(g, { warnOnEmpty: false }), + normalizeGlossaryInfo(g, { warnOnEmpty: false }) ); } catch (error) { throw this.handleError(error, 'listGlossaries'); @@ -78,10 +106,7 @@ export class GlossaryClient extends HttpClient { async deleteGlossary(glossaryId: string): Promise { this.validateGlossaryId(glossaryId); - await this.makeRequest( - 'DELETE', - `/v3/glossaries/${glossaryId}` - ); + await this.makeRequest('DELETE', `/v3/glossaries/${glossaryId}`); } async getGlossaryEntries( @@ -121,18 +146,16 @@ export class GlossaryClient extends HttpClient { entries: string ): Promise { this.validateGlossaryId(glossaryId); - await this.makeJsonRequest( - 'PATCH', - `/v3/glossaries/${glossaryId}`, - { - dictionaries: [{ + await this.makeJsonRequest('PATCH', `/v3/glossaries/${glossaryId}`, { + dictionaries: [ + { source_lang: sourceLang.toUpperCase(), target_lang: targetLang.toUpperCase(), entries, entries_format: 'tsv', - }], - } - ); + }, + ], + }); } async replaceGlossaryDictionary( @@ -168,7 +191,9 @@ export class GlossaryClient extends HttpClient { ): Promise { this.validateGlossaryId(glossaryId); if (!updates.name && !updates.dictionaries) { - throw new ValidationError('At least one of name or dictionaries must be provided'); + throw new ValidationError( + 'At least one of name or dictionaries must be provided' + ); } await this.makeJsonRequest( 'PATCH', diff --git a/src/api/http-client.ts b/src/api/http-client.ts index 52055ff3..bee4f457 100644 --- a/src/api/http-client.ts +++ b/src/api/http-client.ts @@ -45,6 +45,18 @@ export interface DeepLClientOptions { export { sanitizeUrl }; +/** + * The base URL a client built from these options will actually send to. + * + * Exported so that anything which must reflect the endpoint — notably the + * translation and write cache keys, since two endpoints return different text + * for the same request — derives it from the same expression the transport + * uses, rather than a copy that can drift out of step with it. + */ +export function resolveClientBaseUrl(options: DeepLClientOptions): string { + return options.baseUrl ?? (options.usePro ? PRO_API_URL : FREE_API_URL); +} + const DEFAULT_TIMEOUT = 30000; const DEFAULT_MAX_RETRIES = 3; const MAX_SOCKETS = 10; @@ -86,10 +98,41 @@ const CLIENT_ABORT_CODES = new Set([ 'ERR_CANCELED', ]); -/** Per-request overrides for the retry policy and timeouts. */ +/** + * Ceiling on a buffered response body. Every DeepL endpoint except the + * document result returns JSON small enough to fit many times over; the cap + * exists so a hostile or malfunctioning endpoint cannot stream bytes into this + * process until it dies. + */ +export const MAX_RESPONSE_BYTES = 32 * 1024 * 1024; + +/** + * Ceiling on a whole-file transfer. DeepL accepts documents up to 30MB and a + * translated result may exceed its source — notably when `output_format` + * converts between types — so the file paths need headroom the JSON paths do + * not. Applied as the request-body cap for every client and, per request, as + * the response cap for the document download. + */ +export const MAX_TRANSFER_BYTES = 128 * 1024 * 1024; + +/** Per-request overrides for the retry policy, timeouts and transfer caps. */ export interface RequestPolicy { maxRetries?: number; timeout?: number; + maxContentLength?: number; +} + +/** + * Whether axios refused a response for exceeding `maxContentLength`. The + * verdict is deterministic — the same endpoint returns the same oversized body + * — so a replay only re-downloads bytes that will be discarded again. + */ +function isResponseTooLarge(error: AxiosError): boolean { + return ( + error.code === 'ERR_BAD_RESPONSE' && + !error.response && + /^maxContentLength size of \d+ exceeded$/.test(error.message ?? '') + ); } /** @@ -98,11 +141,12 @@ export interface RequestPolicy { * recommended variant for retry-storm dampening: it removes the fixed * lower bound of "equal jitter" entirely, so concurrent clients that * all 429 simultaneously see maximum decorrelation on the next attempt. - * Exported for unit testing; the caller pulls the randomized value - * and passes it straight to `sleep()`. */ export function computeBackoffWithJitter(attempt: number): number { - const cap = Math.min(RETRY_INITIAL_DELAY_MS * 2 ** attempt, RETRY_MAX_DELAY_MS); + const cap = Math.min( + RETRY_INITIAL_DELAY_MS * 2 ** attempt, + RETRY_MAX_DELAY_MS + ); return Math.floor(Math.random() * cap); } @@ -120,11 +164,12 @@ export class HttpClient { protected requestTimeout: number; protected totalTimeout: number; protected _lastTraceId?: string; + /** Origin of `baseURL`, so a verbose request line says where it went. */ + private readonly baseOrigin: string; /** * Standard NO_PROXY matching: `*` bypasses everything, a leading dot or `*.` - * matches subdomains, and an entry may carry a port. Without this a corporate - * HTTPS_PROXY was applied even to a localhost endpoint. + * matches subdomains, and an entry may carry a port. */ private static isProxyBypassed(targetUrl: string): boolean { const noProxy = process.env['NO_PROXY'] ?? process.env['no_proxy']; @@ -158,7 +203,9 @@ export class HttpClient { }); } - private static parseProxyFromEnv(targetUrl?: string): ProxyConfig | undefined { + private static parseProxyFromEnv( + targetUrl?: string + ): ProxyConfig | undefined { if (targetUrl !== undefined && HttpClient.isProxyBypassed(targetUrl)) { return undefined; } @@ -213,8 +260,19 @@ export class HttpClient { throw new AuthError('API key is required'); } - const baseURL = - options.baseUrl ?? (options.usePro ? PRO_API_URL : FREE_API_URL); + // The key is attached to every request below, so this is the one place that + // sees whichever key won precedence — including a config-file key, which + // the redactor cannot discover from the environment. + Logger.registerSecret(apiKey); + + const baseURL = resolveClientBaseUrl(options); + this.baseOrigin = (() => { + try { + return new URL(baseURL).origin; + } catch { + return baseURL; + } + })(); this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; this.requestTimeout = options.timeout ?? DEFAULT_TIMEOUT; @@ -224,6 +282,12 @@ export class HttpClient { const axiosConfig: Record = { baseURL, timeout: options.timeout ?? DEFAULT_TIMEOUT, + // Axios defaults both of these to -1, meaning unbounded, so a server that + // streams without end is buffered until the process dies. The response + // cap is deliberately the tighter of the two; the document download + // raises it per request. + maxContentLength: MAX_RESPONSE_BYTES, + maxBodyLength: MAX_TRANSFER_BYTES, headers: { Authorization: `DeepL-Auth-Key ${apiKey}`, 'User-Agent': USER_AGENT, @@ -247,18 +311,16 @@ export class HttpClient { const proxyConfig = options.proxy ?? HttpClient.parseProxyFromEnv(baseURL); if (proxyConfig) { - // SECURITY: a plain-http proxy sitting in front of an https: API - // endpoint is a MITM footgun. axios tunnels via CONNECT so TLS is - // nominally end-to-end, but a misconfigured or compromised proxy - // env var routes every DeepL call — including the Authorization - // header — through attacker infrastructure. Warn loud at startup; - // don't refuse the connection (users with legitimate corporate - // http-only proxies need the escape hatch). + // SECURITY: axios tunnels via CONNECT so TLS is nominally end-to-end, + // but a plain-http proxy in front of an https: endpoint is a MITM + // footgun — a compromised one that terminates TLS sees every DeepL call, + // Authorization header included. Warn rather than refuse: users with + // legitimate corporate http-only proxies need the escape hatch. if (proxyConfig.protocol === 'http' && baseURL.startsWith('https:')) { Logger.warn( `Warning: routing HTTPS traffic to ${baseURL} via HTTP proxy ${proxyConfig.host}:${proxyConfig.port}. ` + - `TLS is tunneled end-to-end via CONNECT, but a malicious proxy that terminates TLS would see the Authorization header. ` + - `Set HTTPS_PROXY to an https:// URL if possible, or unset it if the proxy isn't required.`, + `TLS is tunneled end-to-end via CONNECT, but a malicious proxy that terminates TLS would see the Authorization header. ` + + `Set HTTPS_PROXY to an https:// URL if possible, or unset it if the proxy isn't required.` ); } axiosConfig['proxy'] = { @@ -275,8 +337,7 @@ export class HttpClient { destroy(): void { const httpAgent = this.client.defaults?.httpAgent as http.Agent | undefined; const httpsAgent = this.client.defaults?.httpsAgent as - | https.Agent - | undefined; + https.Agent | undefined; httpAgent?.destroy(); httpsAgent?.destroy(); } @@ -375,6 +436,20 @@ export class HttpClient { let traceId: string | undefined; for (let attempt = 0; attempt <= maxRetries; attempt++) { + const attemptTimeout = Math.min(requestTimeout, remainingBudget); + // The axios `timeout` reaches the socket as an inactivity timeout, so a + // response body that trickles indefinitely resets it on every chunk and + // it never fires. This deadline is wall-clock and cannot be reset by the + // peer. + const controller = new AbortController(); + let deadlineExpired = false; + const deadline = + attemptTimeout > 0 + ? setTimeout(() => { + deadlineExpired = true; + controller.abort(); + }, attemptTimeout) + : undefined; const requestStart = Date.now(); try { const config = buildConfig(); @@ -383,45 +458,57 @@ export class HttpClient { method, url: path, ...config, - timeout: Math.min(requestTimeout, remainingBudget), + timeout: attemptTimeout, + signal: controller.signal, + ...(policy?.maxContentLength !== undefined && { + maxContentLength: policy.maxContentLength, + }), }); const requestElapsed = Date.now() - requestStart; Logger.verbose( - `[verbose] HTTP ${method} ${path} completed in ${requestElapsed}ms (status ${response.status})` + `[verbose] HTTP ${method} ${this.baseOrigin}${path} completed in ${requestElapsed}ms (status ${response.status})` ); const responseTraceId = response.headers?.['x-trace-id'] as - | string - | undefined; + string | undefined; if (responseTraceId) { this._lastTraceId = responseTraceId; } return response.data; } catch (error) { + // An abort the deadline caused surfaces as a CanceledError that still + // carries the 200 response, which classification would report as an + // "API error". Restate it as the socket timeout it stands in for so + // every downstream decision — replay eligibility, classification, + // message — matches what a non-trickling timeout produces. + const failure = deadlineExpired + ? new AxiosError( + `timeout of ${attemptTimeout}ms exceeded`, + AxiosError.ETIMEDOUT + ) + : error; remainingBudget -= Date.now() - requestStart; - lastError = error as Error; + lastError = failure as Error; - if (this.isAxiosError(error)) { - const responseTraceId = error.response?.headers?.['x-trace-id'] as - | string - | undefined; + if (this.isAxiosError(failure)) { + const responseTraceId = failure.response?.headers?.['x-trace-id'] as + string | undefined; if (responseTraceId) { traceId = responseTraceId; this._lastTraceId = responseTraceId; } - const status = error.response?.status; + const status = failure.response?.status; if (status === 429 && attempt < maxRetries) { const retryAfterDelay = this.parseRetryAfter( - error.response?.headers?.['retry-after'] as string | undefined + failure.response?.headers?.['retry-after'] as string | undefined ); // Respect Retry-After verbatim when present; otherwise use // backoff with full jitter. Jitter prevents concurrent sync // buckets that all 429 at the same moment from forming a // thundering herd on the next attempt. - const delay = - retryAfterDelay ?? computeBackoffWithJitter(attempt); + const delay = retryAfterDelay ?? computeBackoffWithJitter(attempt); Logger.verbose( `[verbose] HTTP ${method} ${path} retry ${attempt + 1}/${maxRetries} in ${delay}ms (status 429${retryAfterDelay !== null && retryAfterDelay !== undefined ? ', Retry-After' : ', jitter backoff'})` ); @@ -429,17 +516,19 @@ export class HttpClient { continue; } if (status && status >= 400 && status < 500) { - throw this.handleError(error, undefined, traceId); + throw this.handleError(failure, undefined, traceId); } } if ( attempt < maxRetries && remainingBudget > 0 && - this.isReplayable(method, error) + this.isReplayable(method, failure) ) { const delay = computeBackoffWithJitter(attempt); - const status = this.isAxiosError(error) ? error.response?.status : undefined; + const status = this.isAxiosError(failure) + ? failure.response?.status + : undefined; Logger.verbose( `[verbose] HTTP ${method} ${path} retry ${attempt + 1}/${maxRetries} in ${delay}ms (${status ? `status ${status}` : 'network error'}, jitter backoff)` ); @@ -448,6 +537,8 @@ export class HttpClient { } break; + } finally { + clearTimeout(deadline); } } @@ -465,6 +556,9 @@ export class HttpClient { if (!this.isAxiosError(error)) { return false; } + if (isResponseTooLarge(error)) { + return false; + } if (!error.response && error.code && UNSENT_REQUEST_CODES.has(error.code)) { return true; } @@ -500,15 +594,14 @@ export class HttpClient { if (this.isAxiosError(error)) { const status = error.response?.status; const responseData = error.response?.data as - | { message?: string } - | undefined; + { message?: string } | undefined; // Sanitize the server-returned message before any interpolation into // user-facing error strings. Defense-in-depth against a malicious or // buggy server scribbling ANSI escape codes / control chars on the // user's terminal, matching the sanitization in tms-client.ts. - // Coalesce to '' before sanitizing — some axios error shapes have no - // `.message` field, and sanitizeForTerminal expects a string. - const message = sanitizeForTerminal(responseData?.message ?? error.message ?? ''); + const message = sanitizeForTerminal( + responseData?.message ?? error.message ?? '' + ); switch (status) { case 401: @@ -546,6 +639,19 @@ export class HttpClient { if (!error.response) { return this.transportError(error); } + // A response whose status the server chose to signal success, on an + // error, means the exchange was accepted and then failed while its + // body was read — cut off mid-body, or a body the transport could + // not decode. The status is what decides it: the axios code varies + // with how the peer failed (ERR_BAD_RESPONSE for an aborted stream, + // a socket or zlib code otherwise). Deadline aborts are restated + // before classification (see `makeRequest`), so what arrives here is + // the peer's doing. + if (status !== undefined && status >= 200 && status < 300) { + return new NetworkError( + `Network error: the API answered HTTP ${status} but its response body did not arrive intact: ${message}${traceIdSuffix}` + ); + } return new ValidationError(`API error: ${message}${traceIdSuffix}`); } } @@ -561,6 +667,17 @@ export class HttpClient { } private transportError(error: AxiosError): NetworkError { + if (isResponseTooLarge(error)) { + const cap = error.config?.maxContentLength; + const limit = + typeof cap === 'number' && cap > 0 + ? `${Math.round(cap / (1024 * 1024))}MiB` + : 'configured'; + return new NetworkError( + `Network error: response body exceeded the ${limit} size limit` + ); + } + const detail = sanitizeForTerminal(error.message ?? ''); const label = error.code && CLIENT_ABORT_CODES.has(error.code) diff --git a/src/api/response-shape.ts b/src/api/response-shape.ts new file mode 100644 index 00000000..d86dd5ba --- /dev/null +++ b/src/api/response-shape.ts @@ -0,0 +1,106 @@ +import { NetworkError } from '../utils/errors.js'; + +/** + * Shape checks for DeepL response bodies. + * + * A response body is untrusted input: the endpoint can be redirected by + * `--api-url`, an `api.baseUrl` in the config, or a proxy, and a redirected host + * can return well-formed JSON of any shape. Declaring a TypeScript interface for + * the body asserts nothing at runtime. + * + * The policy matches `sanitizePullKeysResponse`: reject rather than coerce, and + * name the offending type so the endpoint is diagnosable. + */ + +function describe(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + return typeof value; +} + +/** + * Require `body` to be `{ [field]: Array }` and return that array. + * + * A string is the case a truthiness-plus-length guard cannot catch: it has a + * truthy `.length` but indexing it yields characters rather than items, so the + * array-ness has to be tested explicitly. + */ +export function requireItemArray( + body: unknown, + field: string, + context: string +): Record[] { + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + throw new NetworkError( + `Unexpected API response: expected a JSON object with "${field}", got ${describe(body)}. ${context}` + ); + } + + const items = (body as Record)[field]; + + // A missing or null field means nothing was returned, which is not a type + // error: callers already describe that case better than a shape message can + // ("No translation returned from DeepL API"), so it becomes an empty array and + // falls through to their own length check. + if (items === undefined || items === null) return []; + + if (!Array.isArray(items)) { + throw new NetworkError( + `Unexpected API response: "${field}" must be an array, got ${describe(items)}. ${context}` + ); + } + + return items as Record[]; +} + +/** + * Require `items[index]` to be an object whose `text` is a string, and return + * that string. + */ +export function requireItemText( + items: Record[], + index: number, + field: string, + context: string +): string { + const item = items[index]; + if (item === null || typeof item !== 'object' || Array.isArray(item)) { + throw new NetworkError( + `Unexpected API response: "${field}[${index}]" must be an object, got ${describe(item)}. ${context}` + ); + } + const text = item['text']; + if (typeof text !== 'string') { + throw new NetworkError( + `Unexpected API response: "${field}[${index}].text" must be a string, got ${describe(text)}. ${context}` + ); + } + return text; +} + +/** + * An optional response field, kept only when it is actually the expected type. + * + * Dropping a wrong-typed value rather than rejecting the whole response is + * deliberate: these are metadata, not the translation. A bogus + * `billed_characters` must not be summed into a cost report or land in a + * lockfile, but it is no reason to discard a translation that is already billed. + */ +export function optionalString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +export function optionalNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) + ? value + : undefined; +} + +/** `optionalNumber` for a field of a body whose shape is not yet narrowed. */ +export function optionalNumberField( + body: unknown, + name: string +): number | undefined { + if (body === null || typeof body !== 'object') return undefined; + return optionalNumber((body as Record)[name]); +} diff --git a/src/api/style-rules-client.ts b/src/api/style-rules-client.ts index 11e9f8f3..571e5569 100644 --- a/src/api/style-rules-client.ts +++ b/src/api/style-rules-client.ts @@ -19,12 +19,16 @@ interface CustomInstructionWireShape { source_language?: string; } -function mapCustomInstruction(wire: CustomInstructionWireShape): CustomInstruction { +function mapCustomInstruction( + wire: CustomInstructionWireShape +): CustomInstruction { return { ...(wire.id !== undefined && { id: wire.id }), label: wire.label, prompt: wire.prompt, - ...(wire.source_language !== undefined && { sourceLanguage: wire.source_language }), + ...(wire.source_language !== undefined && { + sourceLanguage: wire.source_language, + }), }; } @@ -54,7 +58,9 @@ function mapStyleRuleDetailed(wire: StyleRuleWireShape): StyleRuleDetailed { return { ...mapStyleRule(wire), configuredRules: wire.configured_rules ?? {}, - customInstructions: (wire.custom_instructions ?? []).map(mapCustomInstruction), + customInstructions: (wire.custom_instructions ?? []).map( + mapCustomInstruction + ), }; } @@ -85,7 +91,7 @@ export class StyleRulesClient extends HttpClient { }>('GET', '/v3/style_rules', params); return response.style_rules.map((rule) => - options.detailed ? mapStyleRuleDetailed(rule) : mapStyleRule(rule), + options.detailed ? mapStyleRuleDetailed(rule) : mapStyleRule(rule) ); } @@ -98,7 +104,7 @@ export class StyleRulesClient extends HttpClient { body['configured_rules'] = options.configuredRules; } if (options.customInstructions !== undefined) { - body['custom_instructions'] = options.customInstructions.map(ci => ({ + body['custom_instructions'] = options.customInstructions.map((ci) => ({ label: ci.label, prompt: ci.prompt, ...(ci.sourceLanguage && { source_language: ci.sourceLanguage }), @@ -107,12 +113,15 @@ export class StyleRulesClient extends HttpClient { const wire = await this.makeJsonRequest( 'POST', '/v3/style_rules', - body, + body ); return mapStyleRule(wire); } - async getStyleRule(styleId: string, detailed = false): Promise { + async getStyleRule( + styleId: string, + detailed = false + ): Promise { const params: Record = {}; if (detailed) { params['detailed'] = true; @@ -121,12 +130,15 @@ export class StyleRulesClient extends HttpClient { 'GET', `/v3/style_rules/${encodeURIComponent(styleId)}`, undefined, - params, + params ); return detailed ? mapStyleRuleDetailed(wire) : mapStyleRule(wire); } - async updateStyleRule(styleId: string, options: UpdateStyleRuleOptions): Promise { + async updateStyleRule( + styleId: string, + options: UpdateStyleRuleOptions + ): Promise { const body: Record = {}; if (options.name !== undefined) { body['name'] = options.name; @@ -135,7 +147,7 @@ export class StyleRulesClient extends HttpClient { body['configured_rules'] = options.configuredRules; } if (options.customInstructions !== undefined) { - body['custom_instructions'] = options.customInstructions.map(ci => ({ + body['custom_instructions'] = options.customInstructions.map((ci) => ({ label: ci.label, prompt: ci.prompt, ...(ci.sourceLanguage && { source_language: ci.sourceLanguage }), @@ -144,7 +156,7 @@ export class StyleRulesClient extends HttpClient { const wire = await this.makeJsonRequest( 'PATCH', `/v3/style_rules/${encodeURIComponent(styleId)}`, - body, + body ); return mapStyleRule(wire); } @@ -152,25 +164,28 @@ export class StyleRulesClient extends HttpClient { async deleteStyleRule(styleId: string): Promise { await this.makeJsonRequest( 'DELETE', - `/v3/style_rules/${encodeURIComponent(styleId)}`, + `/v3/style_rules/${encodeURIComponent(styleId)}` ); } - async replaceConfiguredRules(styleId: string, rules: ConfiguredRules): Promise { + async replaceConfiguredRules( + styleId: string, + rules: ConfiguredRules + ): Promise { // The PUT endpoint at /configured_rules takes the rules dict as the entire body // (no `configured_rules` outer wrapper). The wrapper is only used on POST /v3/style_rules // and PATCH /v3/style_rules/{id} where the body has multiple top-level fields. const wire = await this.makeJsonRequest( 'PUT', `/v3/style_rules/${encodeURIComponent(styleId)}/configured_rules`, - rules, + rules ); return mapStyleRuleDetailed(wire); } async createCustomInstruction( styleId: string, - options: CreateCustomInstructionOptions, + options: CreateCustomInstructionOptions ): Promise { const body: Record = { label: options.label, @@ -182,7 +197,7 @@ export class StyleRulesClient extends HttpClient { const wire = await this.makeJsonRequest( 'POST', `/v3/style_rules/${encodeURIComponent(styleId)}/custom_instructions`, - body, + body ); return mapCustomInstruction(wire); } @@ -194,22 +209,31 @@ export class StyleRulesClient extends HttpClient { * instructions by label. This helper does the lookup via a detailed * `getStyleRule`. Throws ValidationError if no instruction with that label exists. */ - private async resolveInstructionId(styleId: string, label: string): Promise { - const detailed = await this.getStyleRule(styleId, true) as StyleRuleDetailed; - const found = detailed.customInstructions.find(ci => ci.label === label); + private async resolveInstructionId( + styleId: string, + label: string + ): Promise { + const detailed = (await this.getStyleRule( + styleId, + true + )) as StyleRuleDetailed; + const found = detailed.customInstructions.find((ci) => ci.label === label); if (!found?.id) { throw new ValidationError( - `No custom instruction with label "${label}" found on style rule ${styleId}.`, + `No custom instruction with label "${label}" found on style rule ${styleId}.` ); } return found.id; } - async getCustomInstruction(styleId: string, label: string): Promise { + async getCustomInstruction( + styleId: string, + label: string + ): Promise { const instructionId = await this.resolveInstructionId(styleId, label); const wire = await this.makeJsonRequest( 'GET', - `/v3/style_rules/${encodeURIComponent(styleId)}/custom_instructions/${encodeURIComponent(instructionId)}`, + `/v3/style_rules/${encodeURIComponent(styleId)}/custom_instructions/${encodeURIComponent(instructionId)}` ); return mapCustomInstruction(wire); } @@ -217,7 +241,7 @@ export class StyleRulesClient extends HttpClient { async updateCustomInstruction( styleId: string, label: string, - options: UpdateCustomInstructionOptions, + options: UpdateCustomInstructionOptions ): Promise { const instructionId = await this.resolveInstructionId(styleId, label); // The PUT body requires `label` even though `instruction_id` appears in the URL path. @@ -231,7 +255,7 @@ export class StyleRulesClient extends HttpClient { const wire = await this.makeJsonRequest( 'PUT', `/v3/style_rules/${encodeURIComponent(styleId)}/custom_instructions/${encodeURIComponent(instructionId)}`, - body, + body ); return mapCustomInstruction(wire); } @@ -240,7 +264,7 @@ export class StyleRulesClient extends HttpClient { const instructionId = await this.resolveInstructionId(styleId, label); await this.makeJsonRequest( 'DELETE', - `/v3/style_rules/${encodeURIComponent(styleId)}/custom_instructions/${encodeURIComponent(instructionId)}`, + `/v3/style_rules/${encodeURIComponent(styleId)}/custom_instructions/${encodeURIComponent(instructionId)}` ); } } diff --git a/src/api/translation-client.ts b/src/api/translation-client.ts index 21a62601..4b2294aa 100644 --- a/src/api/translation-client.ts +++ b/src/api/translation-client.ts @@ -1,7 +1,20 @@ import { HttpClient, DeepLClientOptions } from './http-client.js'; -import { TranslationOptions, Language, TranslationMemory } from '../types/index.js'; +import { + requireItemArray, + requireItemText, + optionalString, + optionalNumber, + optionalNumberField, +} from './response-shape.js'; +import { + TranslationOptions, + Language, + TranslationMemory, +} from '../types/index.js'; import { NetworkError } from '../utils/errors.js'; import { normalizeFormality } from '../utils/formality.js'; +import { resolveGlossaryWireParams } from '../utils/glossary-params.js'; +import { resolveTagHandlingVersion } from '../utils/tag-handling-version.js'; import { Logger } from '../utils/logger.js'; // DeepL's /v3/translation_memories endpoint paginates via `page` (0-indexed) and @@ -10,16 +23,6 @@ import { Logger } from '../utils/logger.js'; export const TRANSLATION_MEMORY_LIST_PAGE_SIZE = 25; export const MAX_TRANSLATION_MEMORY_LIST_PAGES = 20; -interface DeepLTranslateResponse { - translations: Array<{ - detected_source_language?: string; - text: string; - billed_characters?: number; - model_type_used?: string; - }>; - billed_characters?: number; -} - interface DeepLUsageResponse { character_count: number; character_limit: number; @@ -29,8 +32,6 @@ interface DeepLUsageResponse { api_key_unit_limit?: number; account_unit_count?: number; account_unit_limit?: number; - speech_to_text_milliseconds_count?: number; - speech_to_text_milliseconds_limit?: number; start_time?: string; end_time?: string; products?: Array<{ @@ -38,15 +39,19 @@ interface DeepLUsageResponse { character_count: number; api_key_character_count: number; unit_count?: number; + account_unit_count?: number; api_key_unit_count?: number; billing_unit?: string; }>; } -interface DeepLLanguageResponse { - language: string; +interface DeepLV3LanguageResponse { + lang: string; name: string; - supports_formality?: boolean; + status?: string; + usable_as_source?: boolean; + usable_as_target?: boolean; + features?: LanguageFeatures; } export interface TranslationResult { @@ -58,6 +63,47 @@ export interface TranslationResult { cached?: boolean; } +/** + * Whether `returned` is the submitted `texts` themselves, rearranged. + * + * /v2/translate correlates a translation to its request item by position and + * nothing else, so a same-length reordered response passes the length check and + * gets written under the wrong i18n key at exit 0. This catches the one + * reordering that is provable from the response alone; an endpoint that returns + * plausible translations in the wrong order stays undetectable, because the + * protocol carries no per-item identity to check against. + * + * Both conditions are required. Multiset equality alone fires on a legitimate + * identity translation (source language equal to target). A displaced position + * alone fires whenever one item's translation happens to equal another item's + * source text, which a partially translated file produces. + */ +function isReorderedEcho(texts: string[], returned: string[]): boolean { + const remaining = new Map(); + for (const text of texts) { + remaining.set(text, (remaining.get(text) ?? 0) + 1); + } + + let displaced = false; + for (let i = 0; i < returned.length; i++) { + const text = returned[i]!; + const count = remaining.get(text); + if (count === undefined) { + return false; + } + if (count === 1) { + remaining.delete(text); + } else { + remaining.set(text, count - 1); + } + if (text !== texts[i]) { + displaced = true; + } + } + + return displaced && remaining.size === 0; +} + export function isTranslationResult(data: unknown): data is TranslationResult { if (data === null || typeof data !== 'object') { return false; @@ -71,6 +117,11 @@ export interface ProductUsage { characterCount: number; apiKeyCharacterCount: number; unitCount?: number; + /** + * Account-wide units, which is what duration-billed products report instead of + * `unit_count`. + */ + accountUnitCount?: number; apiKeyUnitCount?: number; billingUnit?: string; } @@ -84,17 +135,29 @@ export interface UsageInfo { apiKeyUnitLimit?: number; accountUnitCount?: number; accountUnitLimit?: number; - speechToTextMillisecondsCount?: number; - speechToTextMillisecondsLimit?: number; startTime?: string; endTime?: string; products?: ProductUsage[]; } +/** + * Per-feature support as reported by GET /v3/languages. A feature is supported + * when its key is present; `status` describes maturity, not availability. + * Known values are `stable`, `beta` and `early_access`, but the enum is open, + * so it stays a plain string. + */ +export interface LanguageFeature { + status: string; +} + +/** Feature keys vary by `resource`, so the map is deliberately open-ended. */ +export type LanguageFeatures = Record; + export interface LanguageInfo { language: Language; name: string; supportsFormality?: boolean; + features?: LanguageFeatures; } export class TranslationClient extends HttpClient { @@ -109,28 +172,36 @@ export class TranslationClient extends HttpClient { const params = this.buildTranslationParams([text], options); try { - const response = await this.makeRequest( + // Deliberately `unknown`: a declared interface asserts nothing at + // runtime, and a redirected endpoint can return any shape. + const response = await this.makeRequest( 'POST', '/v2/translate', params ); - if (!response.translations || response.translations.length === 0) { - throw new NetworkError(`No translation returned from DeepL API. Request: translate text (${text.length} chars) to ${options.targetLang}`); - } + const context = `Request: translate text (${text.length} chars) to ${options.targetLang}`; + const items = requireItemArray(response, 'translations', context); - const translation = response.translations[0]; - if (!translation) { - throw new NetworkError(`Empty translation in API response. Request: translate text (${text.length} chars) to ${options.targetLang}`); + if (items.length === 0) { + throw new NetworkError( + `No translation returned from DeepL API. ${context}` + ); } + const translated = requireItemText(items, 0, 'translations', context); + const item = items[0]!; + const detected = optionalString(item['detected_source_language']); + return { - text: translation.text, - detectedSourceLang: translation.detected_source_language - ? this.normalizeLanguage(translation.detected_source_language) + text: translated, + detectedSourceLang: detected + ? this.normalizeLanguage(detected) : undefined, - billedCharacters: translation.billed_characters ?? response.billed_characters, - modelTypeUsed: translation.model_type_used, + billedCharacters: + optionalNumber(item['billed_characters']) ?? + optionalNumberField(response, 'billed_characters'), + modelTypeUsed: optionalString(item['model_type_used']), }; } catch (error) { throw this.handleError(error); @@ -148,28 +219,51 @@ export class TranslationClient extends HttpClient { const params = this.buildTranslationParams(texts, options); try { - const response = await this.makeRequest( + // Deliberately `unknown`: a declared interface asserts nothing at + // runtime, and a redirected endpoint can return any shape. + const response = await this.makeRequest( 'POST', '/v2/translate', params ); - if (!response.translations) { - throw new NetworkError('Unexpected API response. Please retry your translation. If the issue persists, report it at https://github.com/DeepL/deepl-cli/issues'); + const context = `Request: translate ${texts.length} texts to ${options.targetLang}`; + const items = requireItemArray(response, 'translations', context); + + if (items.length !== texts.length) { + throw new NetworkError( + 'Unexpected API response. Please retry your translation. If the issue persists, report it at https://github.com/DeepL/deepl-cli/issues' + ); } - if (response.translations.length !== texts.length) { - throw new NetworkError('Unexpected API response. Please retry your translation. If the issue persists, report it at https://github.com/DeepL/deepl-cli/issues'); + // Every text is validated before any is used, so a bad element late in + // the array cannot leave earlier ones already written. + const translatedTexts = items.map((_item, index) => + requireItemText(items, index, 'translations', context) + ); + + if (isReorderedEcho(texts, translatedTexts)) { + throw new NetworkError( + 'The endpoint returned the submitted texts in a different order, so each translation ' + + 'would be stored against the wrong entry. Nothing was written. Check which endpoint ' + + 'this run used (--api-url, or api.baseUrl in your config).' + ); } - return response.translations.map((translation) => ({ - text: translation.text, - detectedSourceLang: translation.detected_source_language - ? this.normalizeLanguage(translation.detected_source_language) - : undefined, - billedCharacters: translation.billed_characters ?? response.billed_characters, - modelTypeUsed: translation.model_type_used, - })); + const topBilled = optionalNumberField(response, 'billed_characters'); + + return items.map((item, index) => { + const detected = optionalString(item['detected_source_language']); + return { + text: translatedTexts[index]!, + detectedSourceLang: detected + ? this.normalizeLanguage(detected) + : undefined, + billedCharacters: + optionalNumber(item['billed_characters']) ?? topBilled, + modelTypeUsed: optionalString(item['model_type_used']), + }; + }); } catch (error) { throw this.handleError(error); } @@ -199,12 +293,6 @@ export class TranslationClient extends HttpClient { if (response.end_time) { usage.endTime = response.end_time; } - if (response.speech_to_text_milliseconds_count !== undefined) { - usage.speechToTextMillisecondsCount = response.speech_to_text_milliseconds_count; - } - if (response.speech_to_text_milliseconds_limit !== undefined) { - usage.speechToTextMillisecondsLimit = response.speech_to_text_milliseconds_limit; - } if (response.api_key_unit_count !== undefined) { usage.apiKeyUnitCount = response.api_key_unit_count; } @@ -218,12 +306,17 @@ export class TranslationClient extends HttpClient { usage.accountUnitLimit = response.account_unit_limit; } if (response.products) { - usage.products = response.products.map(p => ({ + usage.products = response.products.map((p) => ({ productType: p.product_type, characterCount: p.character_count, apiKeyCharacterCount: p.api_key_character_count, ...(p.unit_count !== undefined && { unitCount: p.unit_count }), - ...(p.api_key_unit_count !== undefined && { apiKeyUnitCount: p.api_key_unit_count }), + ...(p.account_unit_count !== undefined && { + accountUnitCount: p.account_unit_count, + }), + ...(p.api_key_unit_count !== undefined && { + apiKeyUnitCount: p.api_key_unit_count, + }), ...(p.billing_unit && { billingUnit: p.billing_unit }), })); } @@ -241,7 +334,9 @@ export class TranslationClient extends HttpClient { total_count?: number; }>('GET', '/v3/translation_memories'); - const aggregated: TranslationMemory[] = [...(first.translation_memories ?? [])]; + const aggregated: TranslationMemory[] = [ + ...(first.translation_memories ?? []), + ]; const total = first.total_count; if (typeof total !== 'number' || aggregated.length >= total) { return aggregated; @@ -273,21 +368,63 @@ export class TranslationClient extends HttpClient { } } + /** + * The raw translate_text language list, fetched at most once per client. The + * request does not vary by role and both roles are filtered out of the one + * payload, so a caller asking for both costs a single request. A failed fetch + * is not retained, leaving the next caller free to retry. + */ + private translateLanguages?: Promise; + + private fetchTranslateLanguages(): Promise { + this.translateLanguages ??= this.makeRequest( + 'GET', + '/v3/languages', + { resource: 'translate_text' } + ).catch((error: unknown) => { + delete this.translateLanguages; + throw error; + }); + return this.translateLanguages; + } + + /** + * Lists languages via GET /v3/languages (v2 is deprecated). One response + * carries both roles as usable_as_source/usable_as_target flags, filtered + * here to preserve the per-type contract. Formality support comes from the + * per-language features matrix, which is what v2's supports_formality became. + */ async getSupportedLanguages( type: 'source' | 'target' ): Promise { try { - const response = await this.makeRequest( - 'GET', - '/v2/languages', - { type } + const response = await this.fetchTranslateLanguages(); + + return ( + response + // `!== false`, not truthiness: an absent flag is not a denial, and the + // language registry reads it the same way, so a truthy filter here would + // drop a language the generator records as usable. + .filter((lang) => + type === 'source' + ? lang.usable_as_source !== false + : lang.usable_as_target !== false + ) + .map((lang) => { + const code = this.normalizeLanguage(lang.lang); + return { + language: code, + name: lang.name, + // Only claimed when the response described this language's features; + // silence about a language is not evidence that formality is absent. + ...(type === 'target' && + lang.features && { + supportsFormality: lang.features['formality'] !== undefined, + }), + ...(lang.features && { features: lang.features }), + }; + }) ); - - return response.map((lang) => ({ - language: this.normalizeLanguage(lang.language), - name: lang.name, - ...(lang.supports_formality !== undefined && { supportsFormality: lang.supports_formality }), - })); } catch (error) { throw this.handleError(error); } @@ -303,20 +440,25 @@ export class TranslationClient extends HttpClient { }; if (options.sourceLang) { - params['source_lang'] = this.normalizeLanguage(options.sourceLang).toUpperCase(); + params['source_lang'] = this.normalizeLanguage( + options.sourceLang + ).toUpperCase(); } if (options.formality) { params['formality'] = normalizeFormality(options.formality, 'text'); } - if (options.glossaryId) { - params['glossary_id'] = options.glossaryId; + const glossaryParams = resolveGlossaryWireParams(options); + if (glossaryParams) { + Object.assign(params, glossaryParams); } if (options.translationMemoryId) { params['translation_memory_id'] = options.translationMemoryId; - params['translation_memory_threshold'] = String(options.translationMemoryThreshold ?? 75); + params['translation_memory_threshold'] = String( + options.translationMemoryThreshold ?? 75 + ); } if (options.preserveFormatting) { @@ -329,7 +471,8 @@ export class TranslationClient extends HttpClient { if (options.splitSentences) { const splitMap: Record = { on: '1', off: '0' }; - params['split_sentences'] = splitMap[options.splitSentences] ?? options.splitSentences; + params['split_sentences'] = + splitMap[options.splitSentences] ?? options.splitSentences; } if (options.tagHandling) { @@ -368,12 +511,9 @@ export class TranslationClient extends HttpClient { params['style_id'] = options.styleId; } - if (options.tagHandlingVersion) { - params['tag_handling_version'] = options.tagHandlingVersion; - } - - if (options.enableBetaLanguages) { - params['enable_beta_languages'] = '1'; + const tagHandlingVersion = resolveTagHandlingVersion(options); + if (tagHandlingVersion) { + params['tag_handling_version'] = tagHandlingVersion; } return params; diff --git a/src/api/voice-client.ts b/src/api/voice-client.ts index d19600e1..dd3b789a 100644 --- a/src/api/voice-client.ts +++ b/src/api/voice-client.ts @@ -165,8 +165,7 @@ export class VoiceClient extends HttpClient { if (this.isAxiosError(error)) { const status = error.response?.status; const responseData = error.response?.data as - | { message?: string } - | undefined; + { message?: string } | undefined; if (status === 403) { return new VoiceError( diff --git a/src/api/write-client.ts b/src/api/write-client.ts index ba708a80..1192bd83 100644 --- a/src/api/write-client.ts +++ b/src/api/write-client.ts @@ -1,5 +1,14 @@ import { HttpClient, DeepLClientOptions } from './http-client.js'; -import { WriteOptions, CorrectOptions, WriteImprovement } from '../types/index.js'; +import { + requireItemArray, + requireItemText, + optionalString, +} from './response-shape.js'; +import { + WriteOptions, + CorrectOptions, + WriteImprovement, +} from '../types/index.js'; import { NetworkError, ValidationError } from '../utils/errors.js'; interface DeepLWriteResponse { @@ -83,14 +92,17 @@ export class WriteClient extends HttpClient { } private mapImprovements(response: DeepLWriteResponse): WriteImprovement[] { - if (!response.improvements || response.improvements.length === 0) { + const context = 'Request: rephrase or correct text'; + const items = requireItemArray(response, 'improvements', context); + + if (items.length === 0) { throw new NetworkError('No improvements returned'); } - return response.improvements.map(improvement => ({ - text: improvement.text, - targetLanguage: improvement.target_language as WriteImprovement['targetLanguage'], - detectedSourceLanguage: improvement.detected_source_language, + return items.map((item, index) => ({ + text: requireItemText(items, index, 'improvements', context), + targetLanguage: optionalString(item['target_language']) ?? '', + detectedSourceLanguage: optionalString(item['detected_source_language']), })); } } diff --git a/src/cli/cache-loader.ts b/src/cli/cache-loader.ts index 3dbbf7c9..3e8a2dd4 100644 --- a/src/cli/cache-loader.ts +++ b/src/cli/cache-loader.ts @@ -19,7 +19,10 @@ type CacheModule = Pick; * here is what keeps the service's in-memory flag from diverging from config: * `cache stats` and the translation path then agree on the same value. */ -export function resolveCacheOptions(config: ConfigService, dbPath: string): CacheServiceOptions { +export function resolveCacheOptions( + config: ConfigService, + dbPath: string +): CacheServiceOptions { const ttlSeconds = config.getValue('cache.ttl'); return { dbPath, @@ -32,7 +35,8 @@ export function resolveCacheOptions(config: ConfigService, dbPath: string): Cach export function createCacheServiceGetter( getOptions: () => CacheServiceOptions, - importCacheModule: () => Promise = () => import('../storage/cache.js'), + importCacheModule: () => Promise = () => + import('../storage/cache.js') ): () => Promise { let instance: CacheService | undefined; let unavailable = false; @@ -50,12 +54,12 @@ export function createCacheServiceGetter( if (isNativeModuleLoadError(error)) { Logger.warn( `Translation cache backend failed to load (${detail}). ` + - 'Your cache database has not been modified. Caching is disabled for this run. ' + - 'Reinstall the CLI, or run it with the Node.js version it was installed with, to restore caching.', + 'Your cache database has not been modified. Caching is disabled for this run. ' + + 'Reinstall the CLI, or run it with the Node.js version it was installed with, to restore caching.' ); } else { Logger.warn( - `Translation cache is unavailable (${detail}). Caching is disabled for this run.`, + `Translation cache is unavailable (${detail}). Caching is disabled for this run.` ); } } diff --git a/src/cli/commands/admin.ts b/src/cli/commands/admin.ts index 0a3bb3e3..e9fc14f7 100644 --- a/src/cli/commands/admin.ts +++ b/src/cli/commands/admin.ts @@ -4,7 +4,12 @@ */ import type { AdminService } from '../../services/admin.js'; -import { AdminApiKey, AdminUsageOptions, AdminUsageReport, UsageBreakdown } from '../../types/index.js'; +import { + AdminApiKey, + AdminUsageOptions, + AdminUsageReport, + UsageBreakdown, +} from '../../types/index.js'; /** * Manages DeepL admin API operations for team accounts. @@ -42,7 +47,11 @@ export class AdminCommand { * @param characters - Maximum characters allowed, or null for unlimited. * @param sttLimit - Optional speech-to-text milliseconds limit. */ - async setKeyLimit(keyId: string, characters: number | null, sttLimit?: number | null): Promise { + async setKeyLimit( + keyId: string, + characters: number | null, + sttLimit?: number | null + ): Promise { return this.service.setApiKeyLimit(keyId, characters, sttLimit); } @@ -69,15 +78,17 @@ export class AdminCommand { lines.push(` ID: ${key.keyId}`); lines.push(` Created: ${key.creationTime}`); if (key.usageLimits?.characters !== undefined) { - const limit = key.usageLimits.characters === null - ? 'unlimited' - : key.usageLimits.characters.toLocaleString(); + const limit = + key.usageLimits.characters === null + ? 'unlimited' + : key.usageLimits.characters.toLocaleString(); lines.push(` Limit: ${limit} characters`); } if (key.usageLimits?.speechToTextMilliseconds !== undefined) { - const sttLimit = key.usageLimits.speechToTextMilliseconds === null - ? 'unlimited' - : this.formatMilliseconds(key.usageLimits.speechToTextMilliseconds); + const sttLimit = + key.usageLimits.speechToTextMilliseconds === null + ? 'unlimited' + : this.formatMilliseconds(key.usageLimits.speechToTextMilliseconds); lines.push(` STT Limit: ${sttLimit}`); } lines.push(''); @@ -98,15 +109,17 @@ export class AdminCommand { lines.push(` Status: ${status}`); lines.push(` Created: ${key.creationTime}`); if (key.usageLimits?.characters !== undefined) { - const limit = key.usageLimits.characters === null - ? 'unlimited' - : key.usageLimits.characters.toLocaleString(); + const limit = + key.usageLimits.characters === null + ? 'unlimited' + : key.usageLimits.characters.toLocaleString(); lines.push(` Limit: ${limit} characters`); } if (key.usageLimits?.speechToTextMilliseconds !== undefined) { - const sttLimit = key.usageLimits.speechToTextMilliseconds === null - ? 'unlimited' - : this.formatMilliseconds(key.usageLimits.speechToTextMilliseconds); + const sttLimit = + key.usageLimits.speechToTextMilliseconds === null + ? 'unlimited' + : this.formatMilliseconds(key.usageLimits.speechToTextMilliseconds); lines.push(` STT Limit: ${sttLimit}`); } return lines.join('\n'); @@ -115,11 +128,21 @@ export class AdminCommand { /** Format a per-product usage breakdown (translation, documents, write, voice). */ private formatBreakdown(usage: UsageBreakdown, indent = ' '): string[] { const lines: string[] = []; - lines.push(`${indent}Total: ${usage.totalCharacters.toLocaleString()}`); - lines.push(`${indent}Translation: ${usage.textTranslationCharacters.toLocaleString()}`); - lines.push(`${indent}Documents: ${usage.documentTranslationCharacters.toLocaleString()}`); - lines.push(`${indent}Write: ${usage.textImprovementCharacters.toLocaleString()}`); - lines.push(`${indent}Voice: ${this.formatMilliseconds(usage.speechToTextMilliseconds)}`); + lines.push( + `${indent}Total: ${usage.totalCharacters.toLocaleString()}` + ); + lines.push( + `${indent}Translation: ${usage.textTranslationCharacters.toLocaleString()}` + ); + lines.push( + `${indent}Documents: ${usage.documentTranslationCharacters.toLocaleString()}` + ); + lines.push( + `${indent}Write: ${usage.textImprovementCharacters.toLocaleString()}` + ); + lines.push( + `${indent}Voice: ${this.formatMilliseconds(usage.speechToTextMilliseconds)}` + ); return lines; } diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts index 3ab3e65e..cdf45bd8 100644 --- a/src/cli/commands/auth.ts +++ b/src/cli/commands/auth.ts @@ -6,7 +6,11 @@ import { ConfigService } from '../../storage/config.js'; import { DeepLClient } from '../../api/deepl-client.js'; import type { DeepLClientOptions } from '../../api/http-client.js'; -import { ValidationError, AuthError, NetworkError } from '../../utils/errors.js'; +import { + ValidationError, + AuthError, + NetworkError, +} from '../../utils/errors.js'; import { resolveEndpoint } from '../../utils/resolve-endpoint.js'; export class AuthCommand { @@ -21,8 +25,10 @@ export class AuthCommand { /** * Set API key and validate it */ - async setKey(apiKey: string, options: { verify?: boolean } = {}): Promise { - // Validate input + async setKey( + apiKey: string, + options: { verify?: boolean } = {} + ): Promise { if (!apiKey || apiKey.trim() === '') { throw new ValidationError('API key cannot be empty'); } @@ -32,11 +38,9 @@ export class AuthCommand { return; } - // Validate with DeepL API by making a test request - // Note: No format validation - let the API determine if the key is valid - // This supports production keys (:fx suffix), free keys, and test keys + // No local format check: the API decides, which keeps production (`:fx`), + // free and test keys all usable. try { - // Use configured API endpoint for validation const configBaseUrl = this.config.getValue('api.baseUrl'); const usePro = this.config.getValue('api.usePro'); const baseUrl = resolveEndpoint({ apiKey, configBaseUrl, usePro }); @@ -55,7 +59,7 @@ export class AuthCommand { if (error instanceof NetworkError) { throw new NetworkError( `Could not reach the DeepL API to validate the key: ${error.message}`, - 'Store the key without validating with --no-verify, or set DEEPL_API_KEY in your environment instead.', + 'Store the key without validating with --no-verify, or set DEEPL_API_KEY in your environment instead.' ); } throw error; @@ -63,7 +67,6 @@ export class AuthCommand { throw new AuthError('Failed to validate API key'); } - // Save to config this.config.set('auth.apiKey', apiKey); } @@ -71,13 +74,10 @@ export class AuthCommand { * Get API key from config or environment */ async getKey(): Promise { - // Check environment variable first (for CI/CD) const envKey = process.env['DEEPL_API_KEY']; - // Check config const configKey = this.config.getValue('auth.apiKey'); - // Prefer config over environment return configKey ?? envKey; } diff --git a/src/cli/commands/cache.ts b/src/cli/commands/cache.ts index d8567e8a..28ddfb77 100644 --- a/src/cli/commands/cache.ts +++ b/src/cli/commands/cache.ts @@ -67,9 +67,10 @@ export class CacheCommand { const totalSizeMB = (stats.totalSize / (1024 * 1024)).toFixed(2); const maxSizeMB = (stats.maxSize / (1024 * 1024)).toFixed(2); const status = stats.enabled ? 'enabled' : 'disabled'; - const percentUsed = stats.maxSize > 0 - ? ((stats.totalSize / stats.maxSize) * 100).toFixed(1) - : '0.0'; + const percentUsed = + stats.maxSize > 0 + ? ((stats.totalSize / stats.maxSize) * 100).toFixed(1) + : '0.0'; return [ `Cache Status: ${status}`, @@ -82,9 +83,10 @@ export class CacheCommand { formatStatsTable(stats: CacheStats): string { const totalSizeMB = (stats.totalSize / (1024 * 1024)).toFixed(2); const maxSizeMB = (stats.maxSize / (1024 * 1024)).toFixed(2); - const percentUsed = stats.maxSize > 0 - ? ((stats.totalSize / stats.maxSize) * 100).toFixed(1) - : '0.0'; + const percentUsed = + stats.maxSize > 0 + ? ((stats.totalSize / stats.maxSize) * 100).toFixed(1) + : '0.0'; const colorDisabled = !isColorEnabled(); const table = new Table({ @@ -99,7 +101,7 @@ export class CacheCommand { ['Entries', String(stats.entries)], ['Used', `${totalSizeMB} MB`], ['Limit', `${maxSizeMB} MB`], - ['Usage', `${percentUsed}%`], + ['Usage', `${percentUsed}%`] ); return table.toString(); diff --git a/src/cli/commands/completion.ts b/src/cli/commands/completion.ts index 521e6bc7..34dbb87a 100644 --- a/src/cli/commands/completion.ts +++ b/src/cli/commands/completion.ts @@ -2,6 +2,17 @@ import type { Command } from 'commander'; export type ShellType = 'bash' | 'zsh' | 'fish'; +/** + * Escapes text for a fish single-quoted string, where only `\\` and `\'` carry + * meaning. Backslashes go first: escaping the quotes first would leave the + * backslashes this adds to be doubled by the second pass, and a description + * ending in a backslash would otherwise escape the closing quote and run the + * rest of the generated line together with it. + */ +function escapeFishSingleQuoted(text: string): string { + return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +} + export class CompletionCommand { private readonly program: Command; @@ -42,7 +53,9 @@ export class CompletionCommand { } private findCommand(name: string): Command | undefined { - return this.program.commands.find((c) => c.name() === name || c.aliases().includes(name)); + return this.program.commands.find( + (c) => c.name() === name || c.aliases().includes(name) + ); } private getCommandOptions(cmdName: string): string[] { @@ -59,7 +72,7 @@ export class CompletionCommand { this.program.options .map((opt) => opt.long) .filter((o): o is string => !!o) - .concat('--help', '--version'), + .concat('--help', '--version') ), ]; } @@ -76,7 +89,9 @@ export class CompletionCommand { } const cmdOpts = this.getCommandOptions(parent); const words = [...subs, ...cmdOpts].join(' '); - subcommandCases.push(` ${parent})\n COMPREPLY=($(compgen -W "${words}" -- "\${cur}"))\n return 0\n ;;`); + subcommandCases.push( + ` ${parent})\n COMPREPLY=($(compgen -W "${words}" -- "\${cur}"))\n return 0\n ;;` + ); } const topLevelWords = [...topLevel, ...globalOpts].join(' '); @@ -153,7 +168,9 @@ complete -F _deepl_completions deepl _describe -t ${safeName}-commands '${parent} subcommand' subcmds }`); - subcommandDispatch.push(` ${parent})\n _deepl_${safeName}\n ;;`); + subcommandDispatch.push( + ` ${parent})\n _deepl_${safeName}\n ;;` + ); } const topLevelDescriptions: string[] = []; @@ -232,7 +249,9 @@ _deepl "$@" for (const cmdName of topLevel) { const cmd = this.findCommand(cmdName); const desc = cmd ? cmd.description() : ''; - lines.push(`complete -c deepl -n '${noSubcmdCondition}' -a '${cmdName}' -d '${desc.replace(/'/g, "\\'")}'`); + lines.push( + `complete -c deepl -n '${noSubcmdCondition}' -a '${cmdName}' -d '${escapeFishSingleQuoted(desc)}'` + ); } for (const opt of globalOpts) { @@ -245,7 +264,7 @@ _deepl "$@" if (shortFlag) { line += ` -s '${shortFlag}'`; } - line += ` -d '${desc.replace(/'/g, "\\'")}'`; + line += ` -d '${escapeFishSingleQuoted(desc)}'`; lines.push(line); } @@ -257,24 +276,29 @@ _deepl "$@" } const parentCmd = this.findCommand(parent); const seenCondition = `__fish_seen_subcommand_from ${parent}`; - const notSeenSub = subs.length > 0 - ? `; and not __fish_seen_subcommand_from ${subs.join(' ')}` - : ''; + const notSeenSub = + subs.length > 0 + ? `; and not __fish_seen_subcommand_from ${subs.join(' ')}` + : ''; lines.push(`# ${parent} subcommands`); if (parentCmd) { for (const sub of this.visibleCommands(parentCmd)) { - const desc = sub.description().replace(/'/g, "\\'"); - lines.push(`complete -c deepl -n '${seenCondition}${notSeenSub}' -a '${sub.name()}' -d '${desc}'`); + const desc = escapeFishSingleQuoted(sub.description()); + lines.push( + `complete -c deepl -n '${seenCondition}${notSeenSub}' -a '${sub.name()}' -d '${desc}'` + ); } } const cmdOpts = this.getCommandOptions(parent); for (const opt of cmdOpts) { const optObj = parentCmd?.options.find((o) => o.long === opt); - const desc = optObj ? optObj.description.replace(/'/g, "\\'") : ''; + const desc = optObj ? escapeFishSingleQuoted(optObj.description) : ''; const longFlag = opt.replace(/^--/, ''); - lines.push(`complete -c deepl -n '${seenCondition}' -l '${longFlag}' -d '${desc}'`); + lines.push( + `complete -c deepl -n '${seenCondition}' -l '${longFlag}' -d '${desc}'` + ); } lines.push(''); } diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts index 570fc39d..2ac0894a 100644 --- a/src/cli/commands/config.ts +++ b/src/cli/commands/config.ts @@ -14,11 +14,11 @@ const BOOLEAN_KEYS = [ 'defaults.preserveFormatting', ]; -const NUMERIC_KEYS = [ - 'cache.maxSize', - 'cache.ttl', - 'watch.debounceMs', -]; +const NUMERIC_KEYS = ['cache.maxSize', 'cache.ttl', 'watch.debounceMs']; + +// Keys whose value is always a list, so a single entry still arrives as an +// array rather than a bare string. +const ARRAY_KEYS = ['tms.allowedServers']; export class ConfigCommand { private config: ConfigService; @@ -33,8 +33,14 @@ export class ConfigCommand { async get(key?: string): Promise { if (key) { const value = this.config.getValue(key); - if (key === 'auth.apiKey' && typeof value === 'string' && value.length > 8) { - return value.substring(0, 4) + '...' + value.substring(value.length - 4); + if ( + key === 'auth.apiKey' && + typeof value === 'string' && + value.length > 8 + ) { + return ( + value.substring(0, 4) + '...' + value.substring(value.length - 4) + ); } return value; } @@ -45,7 +51,6 @@ export class ConfigCommand { * Set config value */ async set(key: string, value: string): Promise { - // Parse value based on type const parsedValue = this.parseValue(key, value); this.config.set(key, parsedValue); } @@ -56,7 +61,6 @@ export class ConfigCommand { async list(): Promise> { const config = this.config.get(); - // Mask sensitive values return this.maskSensitiveValues(config); } @@ -87,34 +91,41 @@ export class ConfigCommand { return lines.join('\n'); } - private flattenConfig(obj: Record, prefix: string, lines: string[]): void { + private flattenConfig( + obj: Record, + prefix: string, + lines: string[] + ): void { for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key; - if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + if ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) + ) { this.flattenConfig(value as Record, fullKey, lines); } else { - const display = value === undefined ? '(not set)' : JSON.stringify(value); + const display = + value === undefined ? '(not set)' : JSON.stringify(value); lines.push(`${fullKey} = ${display}`); } } } - /** - * Parse value based on key - */ private parseValue(key: string, value: string): unknown { - // Handle array values (comma-separated) - if (key.includes('targetLangs') || value.includes(',')) { - return value.split(',').map(v => v.trim()); + if ( + ARRAY_KEYS.includes(key) || + key.includes('targetLangs') || + value.includes(',') + ) { + return value.split(',').map((v) => v.trim()); } - // Auto-coerce string values to booleans for known boolean config keys if (BOOLEAN_KEYS.includes(key)) { if (value === 'true') return true; if (value === 'false') return false; } - // Auto-coerce string values to numbers for known numeric config keys if (NUMERIC_KEYS.includes(key)) { const num = parseInt(value, 10); if (!isNaN(num)) return num; @@ -123,18 +134,20 @@ export class ConfigCommand { return value; } - /** - * Mask sensitive values like API keys - */ - private maskSensitiveValues(config: Record): Record { - const masked = JSON.parse(JSON.stringify(config)) as Record; + private maskSensitiveValues( + config: Record + ): Record { + const masked = JSON.parse(JSON.stringify(config)) as Record< + string, + unknown + >; - // Mask API key if (masked['auth'] && typeof masked['auth'] === 'object') { const auth = masked['auth'] as Record; if (auth['apiKey'] && typeof auth['apiKey'] === 'string') { const apiKey = auth['apiKey']; - auth['apiKey'] = apiKey.substring(0, 4) + '...' + apiKey.substring(apiKey.length - 4); + auth['apiKey'] = + apiKey.substring(0, 4) + '...' + apiKey.substring(apiKey.length - 4); } } diff --git a/src/cli/commands/describe.ts b/src/cli/commands/describe.ts index 680ad990..8a74d371 100644 --- a/src/cli/commands/describe.ts +++ b/src/cli/commands/describe.ts @@ -1,17 +1,32 @@ -import type { Command, Option } from 'commander'; +import type { Argument, Command, Option } from 'commander'; export interface DescribeOption { flags: string; description: string; defaultValue?: unknown; + /** Accepted values, when the option constrains its argument to a fixed set. */ + choices?: string[]; +} + +export interface DescribeArgument { + name: string; + required: boolean; } export interface DescribeCommand { name: string; description: string; aliases: string[]; + /** Positional arguments, in declaration order. */ + arguments: DescribeArgument[]; options: DescribeOption[]; commands: DescribeCommand[]; + /** + * Omitted from `--help`. Hidden commands are part of the parsed surface but + * not the documented one, so a consumer can skip them without knowing their + * names. + */ + hidden: boolean; } function describeOption(opt: Option): DescribeOption { @@ -22,19 +37,45 @@ function describeOption(opt: Option): DescribeOption { if (opt.defaultValue !== undefined) { out.defaultValue = opt.defaultValue; } + if (opt.argChoices !== undefined) { + out.choices = [...opt.argChoices]; + } return out; } -function describeCommand(cmd: Command): DescribeCommand { +function describeArgument(arg: Argument): DescribeArgument { + return { name: arg.name(), required: arg.required }; +} + +/** + * Names the parent lists in `--help`. Commander adds an implicit `help` entry + * that is not among the parent's registered commands, so membership is compared + * by name rather than by identity. + */ +function visibleChildNames(cmd: Command): Set { + return new Set( + cmd + .createHelp() + .visibleCommands(cmd) + .map((child) => child.name()) + ); +} + +function describeCommand(cmd: Command, hidden: boolean): DescribeCommand { + const visible = visibleChildNames(cmd); return { name: cmd.name(), description: cmd.description(), aliases: cmd.aliases(), + arguments: cmd.registeredArguments.map(describeArgument), options: cmd.options.map(describeOption), - commands: cmd.commands.map(describeCommand), + commands: cmd.commands.map((child) => + describeCommand(child, !visible.has(child.name())) + ), + hidden, }; } export function describeProgram(program: Command): DescribeCommand { - return describeCommand(program); + return describeCommand(program, false); } diff --git a/src/cli/commands/detect.ts b/src/cli/commands/detect.ts index 9ce5a045..50dd09a9 100644 --- a/src/cli/commands/detect.ts +++ b/src/cli/commands/detect.ts @@ -18,9 +18,13 @@ export class DetectCommand { } formatJson(result: DetectResult): string { - return JSON.stringify({ - detected_language: result.detectedLanguage, - language_name: result.languageName, - }, null, 2); + return JSON.stringify( + { + detected_language: result.detectedLanguage, + language_name: result.languageName, + }, + null, + 2 + ); } } diff --git a/src/cli/commands/glossary.ts b/src/cli/commands/glossary.ts index 910bf527..28fe9d3b 100644 --- a/src/cli/commands/glossary.ts +++ b/src/cli/commands/glossary.ts @@ -5,7 +5,14 @@ import * as fs from 'fs'; import { GlossaryService } from '../../services/glossary.js'; -import { GlossaryInfo, GlossaryLanguagePair, Language, getTargetLang, getTotalEntryCount, isMultilingual } from '../../types/index.js'; +import { + GlossaryInfo, + GlossaryLanguagePair, + Language, + getTargetLang, + getTotalEntryCount, + isMultilingual, +} from '../../types/index.js'; import { safeReadFileSync } from '../../utils/safe-read-file.js'; import { sanitizeForTerminal } from '../../utils/control-chars.js'; import { ValidationError, ConfigError } from '../../utils/errors.js'; @@ -50,11 +57,9 @@ export class GlossaryCommand { * Show glossary details */ async show(nameOrId: string): Promise { - // Try to get by ID first try { return await this.glossaryService.getGlossary(nameOrId); } catch { - // If failed, try by name const glossary = await this.glossaryService.getGlossaryByName(nameOrId); if (!glossary) { throw new ConfigError(`Glossary not found: ${nameOrId}`); @@ -67,7 +72,6 @@ export class GlossaryCommand { * Delete glossary */ async delete(nameOrId: string): Promise { - // Try to get glossary first to find ID const glossary = await this.show(nameOrId); await this.glossaryService.deleteGlossary(glossary.glossary_id); } @@ -75,9 +79,11 @@ export class GlossaryCommand { /** * Get glossary entries (v3 API - requires target lang for multilingual glossaries) */ - async entries(nameOrId: string, targetLang?: Language): Promise> { + async entries( + nameOrId: string, + targetLang?: Language + ): Promise> { const glossary = await this.show(nameOrId); - // Get target language (will throw if glossary is multilingual and targetLang not provided) const target = getTargetLang(glossary, targetLang); return this.glossaryService.getGlossaryEntries( glossary.glossary_id, @@ -103,7 +109,6 @@ export class GlossaryCommand { targetLang?: Language ): Promise { const glossary = await this.show(nameOrId); - // Get target language (will throw if glossary is multilingual and targetLang not provided) const target = getTargetLang(glossary, targetLang); await this.glossaryService.addEntry( glossary.glossary_id, @@ -124,7 +129,6 @@ export class GlossaryCommand { targetLang?: Language ): Promise { const glossary = await this.show(nameOrId); - // Get target language (will throw if glossary is multilingual and targetLang not provided) const target = getTargetLang(glossary, targetLang); await this.glossaryService.updateEntry( glossary.glossary_id, @@ -144,7 +148,6 @@ export class GlossaryCommand { targetLang?: Language ): Promise { const glossary = await this.show(nameOrId); - // Get target language (will throw if glossary is multilingual and targetLang not provided) const target = getTargetLang(glossary, targetLang); await this.glossaryService.removeEntry( glossary.glossary_id, @@ -170,7 +173,7 @@ export class GlossaryCommand { const glossary = await this.show(nameOrId); await this.glossaryService.updateGlossary(glossary.glossary_id, { name: options.name, - dictionaries: options.dictionaries?.map(dict => ({ + dictionaries: options.dictionaries?.map((dict) => ({ sourceLang: glossary.source_lang, targetLang: dict.targetLang, entries: dict.entries, @@ -181,10 +184,7 @@ export class GlossaryCommand { /** * Rename a glossary (v3 API - uses PATCH) */ - async rename( - nameOrId: string, - newName: string - ): Promise { + async rename(nameOrId: string, newName: string): Promise { const glossary = await this.show(nameOrId); await this.glossaryService.renameGlossary(glossary.glossary_id, newName); } @@ -241,8 +241,8 @@ export class GlossaryCommand { const lines = [ `Name: ${sanitizeForTerminal(glossary.name)}`, `ID: ${glossary.glossary_id}`, - `Source language: ${glossary.source_lang.toUpperCase()}`, - `Target languages: ${glossary.target_langs.map(l => l.toUpperCase()).join(', ')}`, + `Source language: ${glossary.source_lang}`, + `Target languages: ${glossary.target_langs.join(', ')}`, `Type: ${multilingual ? 'Multilingual' : 'Single target'}`, `Total entries: ${totalEntries}`, `Created: ${createdStr}`, @@ -250,8 +250,12 @@ export class GlossaryCommand { if (multilingual) { lines.push('\nLanguage pairs:'); - glossary.dictionaries.forEach(dict => { - lines.push(` ${dict.source_lang.toUpperCase()} → ${dict.target_lang.toUpperCase()}: ${dict.entry_count} entries`); + glossary.dictionaries.forEach((dict) => { + // Lowercased like the summary above; normalizeGlossaryInfo only touches + // the top-level fields. + lines.push( + ` ${dict.source_lang.toLowerCase()} → ${dict.target_lang.toLowerCase()}: ${dict.entry_count} entries` + ); }); } @@ -266,11 +270,12 @@ export class GlossaryCommand { return 'No glossaries found'; } - const lines = glossaries.map(g => { + const lines = glossaries.map((g) => { const totalEntries = getTotalEntryCount(g); - const targetStr = g.target_langs.length === 1 - ? g.target_langs[0] - : `${g.target_langs.length} targets`; + const targetStr = + g.target_langs.length === 1 + ? g.target_langs[0] + : `${g.target_langs.length} targets`; const icon = isMultilingual(g) ? '📚' : '📖'; return `${icon} ${sanitizeForTerminal(g.name)} (${g.source_lang}→${targetStr}) - ${totalEntries} entries`; }); diff --git a/src/cli/commands/hooks.ts b/src/cli/commands/hooks.ts index bdf91348..f5e1f827 100644 --- a/src/cli/commands/hooks.ts +++ b/src/cli/commands/hooks.ts @@ -4,14 +4,50 @@ */ import chalk from 'chalk'; -import { GitHooksService, HookType } from '../../services/git-hooks.js'; +import { + GitHooksService, + HookState, + HookType, +} from '../../services/git-hooks.js'; import { ValidationError } from '../../utils/errors.js'; +const HOOK_STATE_DISPLAY: Record = { + installed: { icon: chalk.green('✓'), text: chalk.green('installed') }, + unverified: { + icon: chalk.yellow('?'), + text: chalk.yellow('installed, no hash recorded (legacy marker)'), + }, + modified: { + icon: chalk.yellow('!'), + text: chalk.yellow('installed, content does not match its recorded hash'), + }, + 'not-installed': { icon: chalk.gray('✗'), text: chalk.gray('not installed') }, +}; + +/** + * A mismatch has two readings the CLI cannot tell apart — a hook the user + * customized, which the documentation invites, and content this CLI never + * wrote — so the note gives both rather than accusing either way. + */ +const MISMATCH_NOTE = [ + 'The content of a hook no longer matches the hash its marker records. That', + 'is expected if you edited the hook yourself. If you did not, replace it:', + ' deepl hooks install ', +]; + +/** + * The hash is unkeyed, so a matching one is not evidence of authorship: anyone + * who can write the hook can write a marker that agrees with it. + */ +const AUTHORSHIP_NOTE = [ + 'A recorded hash detects a change made after the marker was written. It', + 'cannot establish that a hook came from this CLI.', +]; + export class HooksCommand { private gitHooksService: GitHooksService | null = null; constructor(gitDir?: string) { - // Find git directory if not provided const gitDirectory = gitDir ?? GitHooksService.findGitRoot(); if (!gitDirectory) { @@ -22,21 +58,38 @@ export class HooksCommand { } /** - * Install a git hook + * The `core.hooksPath` this repository uses to send hooks outside the working + * tree, or null. The CLI asks before writing there; `install` refuses on its + * own if nobody did. */ - install(hookType: HookType): string { + externalHooksPath(): string | null { + return this.gitHooksService?.externalHooksPath ?? null; + } + + hooksDirectory(): string | null { + return this.gitHooksService?.hooksDirectory ?? null; + } + + install( + hookType: HookType, + options: { allowExternal?: boolean } = {} + ): string { if (!this.gitHooksService) { - throw new ValidationError('Not in a git repository. Run this command from within a git repository.'); + throw new ValidationError( + 'Not in a git repository. Run this command from within a git repository.' + ); } - const result = this.gitHooksService.install(hookType); + const result = this.gitHooksService.install(hookType, options); const lines = [chalk.green(`✓ Installed ${hookType} hook`)]; if (result?.hookPath) { lines.push(chalk.gray(` Path: ${result.hookPath}`)); } if (result?.backupPath) { - lines.push(chalk.gray(` Previous hook backed up to: ${result.backupPath}`)); + lines.push( + chalk.gray(` Previous hook backed up to: ${result.backupPath}`) + ); } return lines.join('\n'); @@ -47,7 +100,9 @@ export class HooksCommand { */ uninstall(hookType: HookType): string { if (!this.gitHooksService) { - throw new ValidationError('Not in a git repository. Run this command from within a git repository.'); + throw new ValidationError( + 'Not in a git repository. Run this command from within a git repository.' + ); } this.gitHooksService.uninstall(hookType); @@ -58,7 +113,7 @@ export class HooksCommand { /** * Return raw hook status data (for JSON output) */ - listData(): Record { + listData(): Record { if (!this.gitHooksService) { return {}; } @@ -76,12 +131,23 @@ export class HooksCommand { const status = this.gitHooksService.list(); const lines = ['Git Hooks Status:', '']; - for (const [hook, installed] of Object.entries(status)) { - const icon = installed ? chalk.green('✓') : chalk.gray('✗'); - const text = installed ? chalk.green('installed') : chalk.gray('not installed'); + for (const [hook, state] of Object.entries(status)) { + const { icon, text } = HOOK_STATE_DISPLAY[state]; lines.push(` ${icon} ${hook.padEnd(15)} ${text}`); } + const states = Object.values(status); + const notes: string[] = []; + if (states.includes('modified')) { + notes.push(...MISMATCH_NOTE); + } + if (states.includes('modified') || states.includes('unverified')) { + notes.push(...AUTHORSHIP_NOTE); + } + if (notes.length > 0) { + lines.push('', ...notes.map((note) => chalk.gray(note))); + } + return lines.join('\n'); } @@ -90,7 +156,9 @@ export class HooksCommand { */ showPath(hookType: HookType): string { if (!this.gitHooksService) { - throw new ValidationError('Not in a git repository. Run this command from within a git repository.'); + throw new ValidationError( + 'Not in a git repository. Run this command from within a git repository.' + ); } const hookPath = this.gitHooksService.getHookPath(hookType); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index e7b0d6bf..0d3924d4 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -52,7 +52,10 @@ export class InitCommand { configBaseUrl, usePro, }); - const client = new DeepLClient(apiKey.trim(), { ...this.httpOptions, baseUrl }); + const client = new DeepLClient(apiKey.trim(), { + ...this.httpOptions, + baseUrl, + }); await client.getUsage(); this.config.set('auth.apiKey', apiKey.trim()); diff --git a/src/cli/commands/languages.ts b/src/cli/commands/languages.ts index 7079fc02..8d64a6fa 100644 --- a/src/cli/commands/languages.ts +++ b/src/cli/commands/languages.ts @@ -1,18 +1,185 @@ import chalk from 'chalk'; import Table from 'cli-table3'; import type { LanguagesService } from '../../services/languages.js'; -import { LanguageInfo } from '../../api/deepl-client.js'; +import { LanguageInfo, type LanguageFeatures } from '../../api/deepl-client.js'; import { getSourceLanguages as getRegistrySourceLanguages, getTargetLanguages as getRegistryTargetLanguages, + deriveLanguageEntry, } from '../../data/language-registry.js'; import { isColorEnabled } from '../../utils/formatters.js'; +import { sanitizeForTerminal } from '../../utils/control-chars.js'; export interface LanguageDisplayEntry { code: string; name: string; category: 'core' | 'regional' | 'extended'; supportsFormality?: boolean; + features?: LanguageFeatures; +} + +/** Display order for the feature keys /v3/languages is known to report. */ +const KNOWN_FEATURE_ORDER = [ + 'formality', + 'glossary', + 'style_rules', + 'translation_memory', + 'tag_handling', + 'auto_detection', +]; + +const FEATURE_LABELS: Record = { + formality: 'Formality', + glossary: 'Glossary', + style_rules: 'Style Rules', + translation_memory: 'Translation Memory', + tag_handling: 'Tag Handling', + auto_detection: 'Auto Detection', +}; + +function featureLabel(key: string): string { + const known = FEATURE_LABELS[key]; + if (known) return known; + // The key is a response field, so it is sanitized before it is displayed. + return sanitizeForTerminal(key) + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +/** Cell text for a language the response carried no feature data for at all. */ +const UNKNOWN_CELL = '?'; + +/** + * Whether the response described this language's features at all. An empty + * matrix is data -- it says the language supports none of them -- while a + * missing one means the language did not appear in the response. + */ +function hasFeatureData(entry: LanguageDisplayEntry): boolean { + return entry.features !== undefined; +} + +/** + * Cell text for one feature on one language. A feature is supported when the + * API reports the key at all; `status` describes maturity, so anything other + * than `stable` is shown verbatim rather than collapsed to yes. `status` is an + * open enum and may be absent, which still means the feature is there. + * + * A language the response omitted reads as unknown rather than unsupported: + * the listing includes snapshot entries the API did not mention, and claiming + * they support nothing would be inventing an answer. + */ +function featureCell(entry: LanguageDisplayEntry, key: string): string { + if (!hasFeatureData(entry)) return UNKNOWN_CELL; + const feature = entry.features?.[key]; + if (!feature) return '—'; + if (!feature.status || feature.status === 'stable') return 'yes'; + // `status` is an open enum echoed verbatim, so it is sanitized like any other + // response field before it reaches the terminal. + return sanitizeForTerminal(feature.status); +} + +function sortFeatureKeys(keys: string[]): string[] { + return [...keys].sort((a, b) => { + const indexA = KNOWN_FEATURE_ORDER.indexOf(a); + const indexB = KNOWN_FEATURE_ORDER.indexOf(b); + if (indexA !== -1 && indexB !== -1) return indexA - indexB; + if (indexA !== -1) return -1; + if (indexB !== -1) return 1; + return a.localeCompare(b); + }); +} + +/** + * Splits the reported features into those worth a column and those every entry + * shares. A uniform feature discriminates nothing, so it is reported once as a + * note instead of repeated on every row. Deriving this from the response rather + * than a fixed list means a new API feature surfaces without a code change. + */ +export function partitionFeatureKeys(entries: LanguageDisplayEntry[]): { + columns: string[]; + uniform: Array<{ key: string; cell: string }>; +} { + // Only languages the response described can say whether a feature varies. + // Counting the rest would make every feature look non-uniform, turning one + // shared by all of them into a column of repeated values. + const described = entries.filter(hasFeatureData); + if (described.length === 0) return { columns: [], uniform: [] }; + + const allKeys = new Set(); + for (const entry of described) { + for (const key of Object.keys(entry.features ?? {})) allKeys.add(key); + } + + const columns: string[] = []; + const uniform: Array<{ key: string; cell: string }> = []; + for (const key of allKeys) { + const first = featureCell(described[0]!, key); + if (described.some((entry) => featureCell(entry, key) !== first)) { + columns.push(key); + } else { + uniform.push({ key, cell: first }); + } + } + + return { + columns: sortFeatureKeys(columns), + uniform: sortFeatureKeys(uniform.map((u) => u.key)).map((key) => + uniform.find((u) => u.key === key)! + ), + }; +} + +function hasAnyFeatures(entries: LanguageDisplayEntry[]): boolean { + return entries.some(hasFeatureData); +} + +/** + * Lowercased feature list for prose contexts, e.g. `glossary, style rules`. + * Empty when there is nothing per-language to say: with no discriminating + * features the footer note carries the answer, so annotating each row `none` + * would contradict it. + */ +function featureList(entry: LanguageDisplayEntry, keys: string[]): string { + if (!hasFeatureData(entry)) return 'no feature data'; + if (keys.length === 0) return ''; + const supported = keys + .filter((key) => featureCell(entry, key) !== '—') + .map((key) => { + const cell = featureCell(entry, key); + const label = featureLabel(key).toLowerCase(); + return cell === 'yes' ? label : `${label} (${cell})`; + }); + if (supported.length > 0) return supported.join(', '); + // Supports none of the columns, but the footer may still be crediting it with + // the features every described language shares; "none" would contradict that. + // Only a language supporting nothing at all is reported as supporting nothing. + return Object.keys(entry.features ?? {}).length === 0 ? 'none' : ''; +} + +/** + * The one-line summary for features every language shares. Scoped to the + * languages the response described when some rows carry no data, since those + * rows are listed too and the note cannot speak for them. + */ +function uniformNote( + uniform: Array<{ key: string; cell: string }>, + entries: LanguageDisplayEntry[] +): string | undefined { + const supported = uniform.filter( + (u) => u.cell !== '—' && u.cell !== UNKNOWN_CELL + ); + if (supported.length === 0) return undefined; + const list = supported + .map((u) => { + const label = featureLabel(u.key).toLowerCase(); + return u.cell === 'yes' ? label : `${label} (${u.cell})`; + }) + .join(', '); + const subject = entries.every(hasFeatureData) + ? 'All listed languages' + : 'All languages with reported features'; + return `${subject} also support: ${list}.`; } export class LanguagesCommand { @@ -31,7 +198,12 @@ export class LanguagesCommand { } /** - * Merge API languages with registry data. API names take precedence. + * Merge API languages with the bundled snapshot. API names take precedence. + * + * The row set is the union of both: a language the API offers but the snapshot + * predates is listed, so `deepl languages` shows what `translate` accepts, and + * snapshot entries the API omits are kept, so a partial response never makes + * languages disappear. */ mergeWithRegistry( apiLanguages: LanguageInfo[], @@ -42,50 +214,103 @@ export class LanguagesCommand { apiMap.set(lang.language.toLowerCase(), lang); } - const registryEntries = type === 'source' - ? getRegistrySourceLanguages() - : getRegistryTargetLanguages(); + const registryEntries = + type === 'source' + ? getRegistrySourceLanguages() + : getRegistryTargetLanguages(); - return registryEntries.map(entry => { + const merged = registryEntries.map((entry) => { const apiLang = apiMap.get(entry.code); return { code: entry.code, name: apiLang?.name ?? entry.name, category: entry.category, - ...(apiLang?.supportsFormality !== undefined && { supportsFormality: apiLang.supportsFormality }), + ...(apiLang?.supportsFormality !== undefined && { + supportsFormality: apiLang.supportsFormality, + }), + ...(apiLang?.features && { features: apiLang.features }), }; }); + + const known = new Set(registryEntries.map((entry) => entry.code)); + for (const lang of apiLanguages) { + const code = lang.language.toLowerCase(); + if (known.has(code)) continue; + // LanguageInfo carries no usable_as_source, and deriving it from the role + // would tier the same code differently in each listing. A regional variant + // always carries a subtag, which is the stable signal available here; core + // and regional render in the same section anyway, and regenerating the + // snapshot replaces the guess with the API's own answer. + const { + code: derivedCode, + name, + category, + } = deriveLanguageEntry({ + lang: code, + name: lang.name, + usable_as_source: !code.includes('-'), + ...(lang.features && { features: lang.features }), + }); + merged.push({ + code: derivedCode, + name, + category, + ...(lang.supportsFormality !== undefined && { + supportsFormality: lang.supportsFormality, + }), + ...(lang.features && { features: lang.features }), + }); + } + + return merged; } /** * Get display entries from registry only (no API call). */ getRegistryLanguages(type: 'source' | 'target'): LanguageDisplayEntry[] { - const entries = type === 'source' - ? getRegistrySourceLanguages() - : getRegistryTargetLanguages(); + const entries = + type === 'source' + ? getRegistrySourceLanguages() + : getRegistryTargetLanguages(); - return entries.map(entry => ({ + return entries.map((entry) => ({ code: entry.code, name: entry.name, category: entry.category, })); } - formatLanguages(languages: LanguageInfo[], type: 'source' | 'target'): string { + formatLanguages( + languages: LanguageInfo[], + type: 'source' | 'target', + showFeatures = false + ): string { if (languages.length === 0 && !this.service.hasClient()) { const displayEntries = this.getRegistryLanguages(type); - return this.formatDisplayEntries(displayEntries, type); + return this.formatDisplayEntries(displayEntries, type, showFeatures); } const displayEntries = this.mergeWithRegistry(languages, type); - return this.formatDisplayEntries(displayEntries, type); + return this.formatDisplayEntries(displayEntries, type, showFeatures); } - formatDisplayEntries(entries: LanguageDisplayEntry[], type: 'source' | 'target'): string { + formatDisplayEntries( + entries: LanguageDisplayEntry[], + type: 'source' | 'target', + showFeatures = false + ): string { const lines: string[] = []; - const header = type === 'source' ? 'Source Languages:' : 'Target Languages:'; - const showFormality = type === 'target' && entries.some(e => e.supportsFormality !== undefined); + const header = + type === 'source' ? 'Source Languages:' : 'Target Languages:'; + const renderFeatures = showFeatures && hasAnyFeatures(entries); + // Formality is one of the feature columns, so the [F] shorthand would say it + // twice. `=== true` because a language the response did not describe carries + // no answer, and a legend with no [F] beneath it reads as "none support it". + const showFormality = + !renderFeatures && + type === 'target' && + entries.some((e) => e.supportsFormality === true); lines.push(chalk.bold(header)); @@ -94,24 +319,43 @@ export class LanguagesCommand { return lines.join('\n'); } - const coreAndRegional = entries.filter(e => e.category === 'core' || e.category === 'regional'); - const extended = entries.filter(e => e.category === 'extended'); + const coreAndRegional = entries.filter( + (e) => e.category === 'core' || e.category === 'regional' + ); + const extended = entries.filter((e) => e.category === 'extended'); const allEntries = [...coreAndRegional, ...extended]; - const maxCodeLength = Math.max(...allEntries.map(e => e.code.length)); + const maxCodeLength = Math.max(...allEntries.map((e) => e.code.length)); + const { columns, uniform } = renderFeatures + ? partitionFeatureKeys(entries) + : { columns: [], uniform: [] }; + const suffix = (entry: LanguageDisplayEntry): string => { + if (!renderFeatures) return ''; + const list = featureList(entry, columns); + return list ? chalk.gray(` — ${list}`) : ''; + }; - coreAndRegional.forEach(entry => { + coreAndRegional.forEach((entry) => { const code = entry.code.padEnd(maxCodeLength + 2); - const formalityMarker = showFormality && entry.supportsFormality ? chalk.green(' [F]') : ''; - lines.push(` ${chalk.cyan(code)} ${entry.name}${formalityMarker}`); + const formalityMarker = + showFormality && entry.supportsFormality ? chalk.green(' [F]') : ''; + lines.push( + ` ${chalk.cyan(code)} ${sanitizeForTerminal(entry.name)}${formalityMarker}${suffix(entry)}` + ); }); if (extended.length > 0) { lines.push(''); - lines.push(chalk.gray(' Extended Languages (quality_optimized only, no formality/glossary):')); - extended.forEach(entry => { + lines.push( + chalk.gray( + ' Extended Languages (quality_optimized only, no formality/glossary):' + ) + ); + extended.forEach((entry) => { const code = entry.code.padEnd(maxCodeLength + 2); - lines.push(` ${chalk.gray(code)} ${chalk.gray(entry.name)}`); + lines.push( + ` ${chalk.gray(code)} ${chalk.gray(sanitizeForTerminal(entry.name))}${suffix(entry)}` + ); }); } @@ -120,32 +364,80 @@ export class LanguagesCommand { lines.push(chalk.gray(' [F] = supports formality parameter')); } + const note = renderFeatures ? uniformNote(uniform, entries) : undefined; + if (note) { + lines.push(''); + lines.push(chalk.gray(` ${note}`)); + } + return lines.join('\n'); } - formatAllLanguages(sourceLanguages: LanguageInfo[], targetLanguages: LanguageInfo[]): string { - const sourcePart = this.formatLanguages(sourceLanguages, 'source'); - const targetPart = this.formatLanguages(targetLanguages, 'target'); + formatAllLanguages( + sourceLanguages: LanguageInfo[], + targetLanguages: LanguageInfo[], + showFeatures = false + ): string { + const sourcePart = this.formatLanguages( + sourceLanguages, + 'source', + showFeatures + ); + const targetPart = this.formatLanguages( + targetLanguages, + 'target', + showFeatures + ); return `${sourcePart}\n\n${targetPart}`; } /** Format a single language list (source or target) as a cli-table3 table. */ - formatLanguagesTable(languages: LanguageInfo[], type: 'source' | 'target'): string { - const entries = languages.length === 0 && !this.service.hasClient() - ? this.getRegistryLanguages(type) - : this.mergeWithRegistry(languages, type); + formatLanguagesTable( + languages: LanguageInfo[], + type: 'source' | 'target', + showFeatures = false + ): string { + const entries = + languages.length === 0 && !this.service.hasClient() + ? this.getRegistryLanguages(type) + : this.mergeWithRegistry(languages, type); + return this.formatDisplayEntriesTable(entries, type, showFeatures); + } + + formatDisplayEntriesTable( + entries: LanguageDisplayEntry[], + type: 'source' | 'target', + showFeatures = false + ): string { const header = type === 'source' ? 'Source Languages' : 'Target Languages'; if (entries.length === 0) { return `${header}: (no languages available)`; } - const showFormality = type === 'target' && entries.some(e => e.supportsFormality !== undefined); - const head = showFormality - ? ['Code', 'Name', 'Category', 'Formality'] - : ['Code', 'Name', 'Category']; - const colWidths = showFormality ? [10, 30, 12, 13] : [10, 36, 12]; + const renderFeatures = showFeatures && hasAnyFeatures(entries); + const { columns, uniform } = renderFeatures + ? partitionFeatureKeys(entries) + : { columns: [], uniform: [] }; + // Formality is one of the feature columns, so the dedicated column would say + // it twice -- unless no feature discriminates, in which case dropping it + // would make --features show strictly less than the plain listing. + const showFormality = + (!renderFeatures || columns.length === 0) && + type === 'target' && + entries.some((e) => e.supportsFormality === true); + + const head = ['Code', 'Name', 'Category']; + const colWidths = [10, renderFeatures ? 24 : showFormality ? 30 : 36, 12]; + if (showFormality) { + head.push('Formality'); + colWidths.push(13); + } + for (const key of columns) { + head.push(featureLabel(key)); + colWidths.push(13); + } const colorDisabled = !isColorEnabled(); const table = new Table({ @@ -156,18 +448,41 @@ export class LanguagesCommand { }); for (const entry of entries) { - const row: string[] = [entry.code, entry.name, entry.category]; + const row: string[] = [ + entry.code, + sanitizeForTerminal(entry.name), + entry.category, + ]; if (showFormality) { row.push(entry.supportsFormality ? 'yes' : '—'); } + for (const key of columns) { + row.push(featureCell(entry, key)); + } table.push(row); } - return `${header}:\n${table.toString()}`; + const notes: string[] = []; + if ( + renderFeatures && + columns.length > 0 && + entries.some((e) => !hasFeatureData(e)) + ) { + notes.push( + `${UNKNOWN_CELL} = the API response did not describe this language` + ); + } + const note = renderFeatures ? uniformNote(uniform, entries) : undefined; + if (note) notes.push(note); + return `${header}:\n${table.toString()}${notes.length > 0 ? `\n${notes.join('\n')}` : ''}`; } /** Format both source and target language tables joined by a blank line. */ - formatAllLanguagesTable(sourceLanguages: LanguageInfo[], targetLanguages: LanguageInfo[]): string { - return `${this.formatLanguagesTable(sourceLanguages, 'source')}\n\n${this.formatLanguagesTable(targetLanguages, 'target')}`; + formatAllLanguagesTable( + sourceLanguages: LanguageInfo[], + targetLanguages: LanguageInfo[], + showFeatures = false + ): string { + return `${this.formatLanguagesTable(sourceLanguages, 'source', showFeatures)}\n\n${this.formatLanguagesTable(targetLanguages, 'target', showFeatures)}`; } } diff --git a/src/cli/commands/parse-int-option.ts b/src/cli/commands/parse-int-option.ts index b897f543..2853c4e5 100644 --- a/src/cli/commands/parse-int-option.ts +++ b/src/cli/commands/parse-int-option.ts @@ -4,15 +4,18 @@ import { InvalidArgumentError } from 'commander'; * Commander option parser for a bounded positive integer. * * A bare `parseInt` yields NaN for a non-numeric value, and NaN survives the - * `??` default chains used downstream — so an invalid `--concurrency` reached - * the worker-pool sizing and silently produced zero workers. Rejecting at the - * boundary means the user is told instead. + * `??` default chains used downstream, so an invalid `--concurrency` would reach + * worker-pool sizing unnoticed. Rejecting at the boundary tells the user instead. */ -export function parsePositiveIntOption(value: string, name: string, max: number): number { +export function parsePositiveIntOption( + value: string, + name: string, + max: number +): number { const parsed = Number.parseInt(value, 10); if (!Number.isInteger(parsed) || parsed <= 0 || parsed > max) { throw new InvalidArgumentError( - `--${name} must be an integer between 1 and ${max}, got '${value}'`, + `--${name} must be an integer between 1 and ${max}, got '${value}'` ); } return parsed; diff --git a/src/cli/commands/register-admin.ts b/src/cli/commands/register-admin.ts index 77f4a875..ea20bcf8 100644 --- a/src/cli/commands/register-admin.ts +++ b/src/cli/commands/register-admin.ts @@ -2,7 +2,11 @@ import { Command, Option } from 'commander'; import chalk from 'chalk'; import { Logger } from '../../utils/logger.js'; import { ValidationError } from '../../utils/errors.js'; -import { createAdminCommand, type CreateDeepLClient, type GetApiKeyAndOptions } from './service-factory.js'; +import { + createAdminCommand, + type CreateDeepLClient, + type GetApiKeyAndOptions, +} from './service-factory.js'; export function registerAdmin( program: Command, @@ -10,14 +14,18 @@ export function registerAdmin( createDeepLClient: CreateDeepLClient; getApiKeyAndOptions?: GetApiKeyAndOptions; handleError: (error: unknown) => never; - }, + } ): void { const { createDeepLClient, handleError } = deps; const adminCmd = program .command('admin') - .description('Admin API: manage API keys and view organization usage (requires admin key)') - .addHelpText('after', ` + .description( + 'Admin API: manage API keys and view organization usage (requires admin key)' + ) + .addHelpText( + 'after', + ` Examples: $ deepl admin keys list $ deepl admin keys create --label "CI/CD key" @@ -27,17 +35,20 @@ Examples: $ deepl admin usage --start 2024-01-01 --end 2024-01-31 $ deepl admin usage --start 2024-01-01 --end 2024-01-31 --group-by key $ deepl admin keys list --format json -`); +` + ); - const adminKeysCmd = adminCmd - .command('keys') - .description('Manage API keys'); + const adminKeysCmd = adminCmd.command('keys').description('Manage API keys'); adminKeysCmd .addCommand( new Command('list') .description('List all API keys') - .addOption(new Option('--format ', 'Output format').choices(['text', 'json']).default('text')) + .addOption( + new Option('--format ', 'Output format') + .choices(['text', 'json']) + .default('text') + ) .action(async (options: { format?: string }) => { try { const admin = await createAdminCommand(createDeepLClient); @@ -56,7 +67,11 @@ Examples: new Command('create') .description('Create a new API key') .option('--label