Skip to content

release: v2.0.0 — publish as @deepl/cli, migrate the cache to node:sqlite - #115

Open
sjsyrek wants to merge 256 commits into
mainfrom
release/v2.0.0
Open

release: v2.0.0 — publish as @deepl/cli, migrate the cache to node:sqlite#115
sjsyrek wants to merge 256 commits into
mainfrom
release/v2.0.0

Conversation

@sjsyrek

@sjsyrek sjsyrek commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Releases 2.0.0 — the first public release of the DeepL CLI, published as the scoped @deepl/cli. The translation cache moves from the better-sqlite3 native addon to Node's built-in node:sqlite, which removes the compilation step entirely and raises the runtime floor. Alongside that: a large correctness pass over the localization sync engine, four new capabilities, and a breaking change to the machine-readable output contract.

How to review this

There are 251 commits and 584 changed files here, so please don't review the raw diff. Two files are the review spec:

  • CHANGELOG.md, the dated ## [2.0.0] section — the authoritative list of every change, grouped by Keep-a-Changelog category.
  • docs/MIGRATION.md — every breaking change with before/after examples, plus an upgrade checklist.

What most deserves scrutiny is the breaking surface (exit codes, JSON output, language-code casing) and the public documentation, not the 7,700 tests.

Changes Made

  • Runtime and packaging — cache backed by node:sqlite instead of better-sqlite3, so there is no native compilation and no ERR_DLOPEN_FAILED after a Node upgrade. engines.node is >=24.15.0. The package ships as @deepl/cli; the command is still deepl. Existing cache databases are read in place, no migration.
  • Localization sync — the largest area of change. Bilingual formats (PO, XLIFF) now read the translation side rather than the source, so reviewed translations are no longer replaced with source text or overwritten with machine output on first run. A target file that cannot be read or parsed is left as it stands instead of being rebuilt from the source. Several failures that used to exit 0 now exit with a real code (8, 10, 12).
  • New capabilitiesdeepl correct (spelling and grammar without rewording); --glossary repeatable up to five, and now applied to document translation; deepl languages --features; JSON payloads for --check, --diff and --alternatives; sync pull --dry-run; --break-lock; global --timeout and --max-retries.
  • Output contract (breaking) — under --format json, a failing command now writes its { ok: false, error, exitCode } envelope to stdout instead of prose to stderr, for every command with a JSON mode. Language codes are displayed lowercase everywhere. Several exit codes moved.
  • CI — a new Packaged artifact job packs the tarball, installs it, and imports the package entry; the test matrix now pins the engines.node floor alongside the latest Node 24.

Backward Compatibility

Breaking changes — this is a major release. Every one is enumerated in CHANGELOG.md and covered with before/after examples in docs/MIGRATION.md. The ones most likely to affect an existing pipeline:

Change Impact
Package renamed to @deepl/cli Install string changes; deepl command unchanged
engines.node >=24.15.0 Older runtimes exit 6 at startup with one clear line
--format json failures go to stdout > out.json now captures failures too
Language codes displayed lowercase Scripts scraping displayed codes see a casing change
Exit codes moved Anything branching on specific codes needs review
Retired flags and tms: keys removed --enable-beta-languages, tms.auto_push/auto_pull/require_review

Preserved — the deepl command name, the on-disk cache format, all wire parameters that are not display output, and text/table output byte-for-byte.

Test Coverage

  • 345 suites, 7,700 tests, all passing. 95.98% statements, 89.60% branches — above the enforced thresholds.
  • Roughly 70–75% unit, 25–30% integration and e2e, with nock for HTTP and DEEPL_CONFIG_DIR for isolation.
  • Local gates green: format:check, lint (--max-warnings 0), type-check, check-deps, test:coverage.
  • The new Packaged artifact job covers a class the suites structurally cannot: they run from the working tree, and bin has its own module graph, so a broken programmatic entry point can ship while every test passes.

Technical Details

The Node floor is a minor version, not just a major, and that is deliberate. node:sqlite emits ExperimentalWarning: SQLite is an experimental feature on every 24.x below 24.15.0 — verified against 24.0.0, 24.5.0, 24.9.0, 24.12.0, 24.13.0, 24.14.0, 24.15.0 and 24.18.x. Since a failing command under --format json now writes to stdout, a stray stderr warning breaks callers that merge the two streams and parse the result. The startup check is therefore major- and minor-aware, and fails open on unparseable versions.

Benefits

  • Install — no compiler toolchain, no native addon to rebuild after a Node upgrade; Homebrew gives a Node-free path.
  • Correctness — the sync engine no longer destroys reviewer work or bills for translations it then withholds; failures that were silent now surface as exit codes.
  • Scriptability — one error envelope on stdout for every JSON-mode command, and consistent lowercase language codes.

Size: Large ✓

251 commits, 584 files, +72,830 / −18,228. Large by volume, but structured as one dated changelog section plus a migration guide — review those rather than the diff.

🤖 Generated with Claude Code

sjsyrek added 30 commits August 3, 2026 06:44
Document translation accepted --glossary and then discarded it: the handler
left glossary out of its supported set, so warnIgnoredOptions reported it as
unsupported and no glossary ever reached the API, even though POST /v2/document
supports them. Resolve the glossary in the document path and drop the warning.

Repeating the flag works here too, with the same last-one-wins precedence.
DocumentTranslationOptions and the document client already carried glossaryIds,
so no client change was needed.

--from is now required with a document glossary, because the API rejects the
request otherwise: "source_lang has to be specified in order to use a
glossary." Translation memories stay unsupported for documents, so the glossary
half of applySharedTmAndGlossary is extracted into applyGlossarySelection and
shared rather than pulling TM resolution into the document path.

The pre-existing test asserting that document mode warns about --glossary
encoded the old behavior and is replaced by one asserting it does not.
Record the repeatable flag, the mandatory --from, and that translation
memories remain unsupported for documents.

Also note that glossary matching is context-dependent for documents exactly as
it is for text — a term applied in one sentence may be left alone in another,
and a bare word list often gets few terms applied. This was mistaken for a
document-specific defect while investigating, so it is written down.
A glossary has always required --from on every path, but the flag was missing
from six documented invocations, so each would have exited 6 if a reader had
copied it. Two predate this work (the API.md glossary-usage block and the
inline help example) and the rest came in with the repeatable flag.

The Commander help for --glossary now states the --from requirement, following
the existing convention of --tm-threshold noting its own, and records that the
flag applies to documents as well.

Add the document glossary examples and feature note to the README, which the
earlier commits left covering only text translation.

Fold the glossary into the document handler's existing "only supported options
produce no warning" test instead of asserting it separately: glossary is now a
supported document option, so it belongs in that list, and a separate test
duplicated the handler suite's own coverage.
/v2/translate rejects a glossary without a source language ("Use of a glossary
requires the source_lang parameter to be specified"), and watch passes --from
straight through without checking it, so --glossary alone fails on every file
change. Say so in the watch option docs and its inline help.

sync needs no equivalent note: it has no --from, taking the source language
from the required source_locale field in .deepl-sync.yaml, so its requests
always carry one.
A voice session that ended with the audio transcribed but no translation
for a requested target language resolved successfully, printing an empty
translation line and exiting 0. A script consuming the output saw success
with the translation missing.

Such a session now rejects with a VoiceError (exit 9) naming the
untranslated languages. Audio containing no speech concludes no source
text and is translated to nothing, which is legitimate, so the check
applies only when a source transcript exists.

The two frame shapes that reach the same place — a whitespace-only
translation and one that stayed tentative without ever concluding — count
as missing, because neither reaches the printed output.

Unit tests that emitted source frames with no target frame have gained one:
under this rule that shape is a failed session, not a valid one.
deepl watch --glossary X without --from started a watch session happily and
then failed on every single file change, surfacing a raw server message each
time. The API rejects any translation naming a glossary without a source
language ("Use of a glossary requires the source_lang parameter to be
specified"), which translate already guards against up front for text, files,
and documents.

watch now rejects the combination before the watcher starts, with the same
message the translate handlers use and a watch-shaped example, exiting 6 as
they do. The check runs before the glossary name is resolved, so it costs no
API call.

The option docs and inline help warned the reader that the check was missing;
they now describe the requirement instead.

sync needs no equivalent guard: it has no --from at all, taking the source
language from the required source_locale field in .deepl-sync.yaml, so its
requests always carry one.
Output mixed three casings: `deepl languages` printed lowercase from the
registry, `glossary show` and `tm list` uppercased at display time,
`translate`'s table uppercased the target language, and `write`/`correct` used
BCP-47 (en-GB, zh-Hans). Lowercase matches the CLI's own normalized form, the
registry, what `deepl languages` teaches users to type, and the wire format
/v3/languages moved to. Uppercase followed a v2-era docs convention that v3
abandons.

Display-time .toUpperCase() is removed from glossary.ts, tm.ts, and
formatters.ts. The v3 glossary and translation-memory endpoints already return
lowercase, so removal is sufficient — verified against the live API.

WRITE_LANGUAGES is lowercased, which changes what write/correct send as
target_lang, not just what they display. The Write API accepts any casing and
canonicalizes server-side: /v2/write/rephrase and /v2/write/correct return 200
for en-gb, zh-hans, and zh-HANS alike and echo back en-GB / zh-Hans. Since the
echoed value is the API's casing rather than ours, WriteImprovement's
targetLanguage is typed as the string it is instead of claiming to be one of
our codes.

Input remains case-insensitive everywhere, so no command line has to change.
Wire parameters that are not display are untouched: translate and the glossary
create endpoint still send uppercase as those endpoints document.

Parseable output shifts casing for scripts: `glossary show` reports
"Source language: en" and "en → es", `tm list` renders
"brand-terms (en → de, fr)", and `write --format json` reports
"language": "en-us".
"should track files added and removed while watching" timed out under load at
roughly 1 run in 20. The cause is not slowness: instrumenting the wait showed
every failure was the add phase with the counter still at 1, while successful
adds land in ~20ms and never approach the 5s budget. fsevents drops the
notification for a file created in the moment after 'ready' fires, while the
stream is still arming, and `ignoreInitial: true` means no rescan recovers it —
so the event never arrives at all.

The wait now re-touches the file every 500ms, which produces a fresh
notification once the stream is delivering. Measured over 200 iterations under
CPU contention: 0 failures, with 10 (5%) recovered by a nudge, matching the
4.5% failure rate seen before. Detection power is unchanged, since a watcher
that fails to count adds never reaches the expected value however often the
file is touched.

The timeout message also reports the count it last saw. "Timed out waiting for
condition" said nothing about whether the counter had stalled or overshot,
which is what made the diagnosis need a separate harness.
GET /v3/languages does report formality support -- as features.formality,
which is what v2's supports_formality became. The static supportsFormality
snapshot added alongside the v3 migration was therefore unnecessary, and it
had already drifted: live v3 reports formality for pt, the registry did not,
so `deepl languages --target` omitted [F] for Portuguese.

Formality now derives from features.formality presence, and the features
matrix is carried through on LanguageInfo for both roles. The 11-entry
snapshot and the supportsFormality field are removed from the registry;
category and targetOnly are untouched.

Verified live: `deepl languages --target` output is byte-identical to before
except pt, which now correctly shows [F].
`deepl languages --features` surfaces the features matrix from
GET /v3/languages, so glossary, style-rules and translation-memory support
no longer has to be discovered by trial and error.

Which columns appear is derived from the response rather than a fixed list: a
feature gets a column when its value differs across the listed languages, and
is otherwise reported once as "All listed languages also support: ...". A
uniform column discriminates nothing, and deriving this from the data means a
newly reported feature appears without a code change. In practice tag_handling
suppresses in both listings and auto_detection only in the source listing,
where every language has it.

Support is key presence; `status` is maturity, so anything other than stable
renders verbatim (`glossary (beta)`) instead of collapsing to yes. --features
subsumes the [F] shorthand, so the marker and its legend are dropped when the
matrix is shown.

Default output is unchanged: text and table rendering are untouched without
the flag, and the matrix is stripped from --format json unless asked for.
Table output gains a seam (formatDisplayEntriesTable) matching the text path
so rendering can be tested without the registry merge.
The languages e2e suite ran offline only, so nothing exercised the command
against a real /v3/languages response. It now spawns the mock server, whose
fixture gains a features object, and asserts the per-language feature list,
that --features replaces the [F] shorthand, and that the matrix reaches
--format json only when requested.

Column suppression stays a unit-level assertion: the row set is the whole
registry, so languages the mock does not return report no features and
nothing comes out uniform.
Covers the flag in API.md, README and the languages example, including the
data-driven column rule, the per-listing difference it produces, and that the
matrix is finer-grained than the core/regional/extended tiers. The example
gains a scriptable preflight snippet using --format json.

CHANGELOG records the feature under Added and the pt formality gap under
Fixed. Extends examples/24-languages.sh rather than adding a script, since
that example already covers the command.
The list of supported languages was hand-maintained, so it could fall behind
the API silently -- and had: de-CH, de-DE, fr-CA and fr-FR were being served
by GET /v3/languages and accepted by the translate endpoint while absent here.
It was verified as an exact match on 2026-08-01 and was four codes short by
2026-08-03.

The data now lives in src/data/language-entries.ts, generated by
scripts/generate-language-registry.mjs from GET /v3/languages;
language-registry.ts keeps the lookup helpers. --check reports drift without
writing, wired up as npm run generate:languages / check:languages.

Tiers are derived rather than assigned: glossary support separates extended
from the rest, source usability separates core from regional. Against the live
response this reproduces the previously hand-assigned tiers exactly -- same
32/82 split, extended set byte-identical -- so the tiers can no longer disagree
with the API either. deriveLanguageEntry is exported because the display path
needs the same rule for codes the snapshot predates.

Regenerating brings the list to 125 languages (32 core, 11 regional, 82
extended) with API names verbatim, which is why de-de is "German" like de and
fr-fr is "French" like fr; the API reports them that way and the snapshot
mirrors it instead of inventing distinct names.
Regenerating the bundled list fixes today's four missing languages but not the
failure mode: a snapshot can always lag the API, and rejecting against it made
languages the API accepts unusable. `deepl translate --to de-CH` failed locally
with "Invalid target language code" while POST /v2/translate answered DE-CH
with 200 and a translation.

A well-formed language code the snapshot does not list is now sent to the API,
which accepts or rejects it authoritatively. Input that is not shaped like a
language tag is still rejected locally with the pointer to `deepl languages`,
so `--to grman` still fails fast without a request; the API answers those with
a plain 400 and no suggestion.

Applies to validateLanguageCodes, covering translate and sync, and to
ConfigService.validateLanguage, which would otherwise still reject de-ch in
config files.
mergeWithRegistry iterated the bundled list and only took names from the API,
so a language the API served but the snapshot lacked was dropped from the
output entirely. That is why `deepl languages` could not show de-DE, and why
the "Run: deepl languages" suggestion on the rejection was a dead end -- the
command could not list the language it was telling the user to look for.

The row set is now the union of both, the API winning on name and features,
the snapshot supplying the tier. Snapshot entries the API omits are kept, so a
partial response never makes languages vanish. For codes the snapshot does not
know, the tier comes from the shared derivation; source usability is inferred
from the presence of a subtag rather than the role being listed, so the same
code is not tiered differently in the source and target listings.
Counts move from 121 to 125 and regional from 7 to 11 across README, API.md
and the languages example. API.md and README gain a section on where the list
comes from: the API is authoritative, the bundled list is a generated snapshot
for offline use, well-formed unknown codes are passed to the API, and the
listing is the union of both. Also notes that the API gives a bare code and its
explicit-region variant the same name, so "German" appearing twice is expected.

CHANGELOG records the four unusable languages under Fixed and the shift of
authority under Changed. The release checklist gains the regeneration step,
since a stale snapshot no longer breaks translation but does go stale for
offline listing and the derived tiers.
…lour

The row assertions anchored on leading whitespace, but the code is chalk.cyan'd,
so wherever colour is forced on the line reads "  \e[36mde-ch    \e[39m German
(Swiss)" and the anchor lands on the escape sequence instead of the code. The
test passed in a NO_COLOR environment and failed in one with FORCE_COLOR set.

Passes noColor so the assertion sees the text it is written against, and
excludeApiKey so the graceful-degradation case reads the snapshot rather than
quietly calling the live API when a key happens to be configured. The rows now
assert code and name together, which also pins de-de and fr-fr carrying the
API's duplicate names.
Generating the language list replaced ten hand-written names with the API's own
(Norwegian Bokmål -> Norwegian (bokmål), Chinese (Simplified) -> Chinese
(simplified), Central Kurdish -> Kurdish (Sorani), and seven more). Worth
recording because this changelog flags output that scripts may scrape, though
the effect is limited to the no-API-key path: with a key the API name already
won at display time, so keyed output is unchanged and offline output now agrees
with it.
Translation memories already refused a name that did not cover the requested
language pair, locally and before any request. Glossaries did not: the request
went out and the API answered "No dictionary found for language pair EN-DE in
glossary <uuid>", naming a UUID the user never typed.

resolveGlossaryId now takes the same optional `expected` pair that
resolveTranslationMemoryId does and reports what the glossary actually covers.
It costs no extra request, because resolving a name already fetches the
glossary list. Matching is per dictionary, so a glossary holding en→es and
de→fr is not read as covering en→fr, and every target of a multi-target
translation must be covered.

Two paths deliberately skip the check: a UUID is trusted and left to the API,
matching translation-memory behaviour and leaving an escape hatch if the check
is ever wrong; and a glossary the API reports with no dictionaries says nothing
about coverage, so it is not rejected on no evidence.

The pair is threaded from the text/file path via applySharedTmAndGlossary and
from the document path, both of which already require --from with --glossary.
Existing call-site assertions now pin the pair rather than just the name.
The Write API's 14 target languages were hand-maintained in two places -- the
WRITE_LANGUAGES array and a separate WriteLanguage union in types/api.ts --
either of which could fall behind GET /v3/languages?resource=write the way the
translation list silently went four codes stale.

The generator now emits both language lists, and check:languages names which
one drifted rather than reporting a misleading translate_text count. The type
is derived from the generated list via `as const`, so a language added upstream
widens it on regenerate instead of needing a second hand edit.

No behaviour change: the generated list is byte-identical to the hand-written
one, and write/correct still reject an unknown code locally while enumerating
every valid option. That strictness is deliberate and not what translate does
-- at 14 of 125 languages, naming the options beats a round trip.

The style/tone support table in API.md stays hand-maintained on purpose: it
records what the API accepts, and resource=write omits writing_style for en
even though --style --lang en works. Noted there so it is not "corrected"
against the metadata later.
… default

The version was sent only when --tag-handling-version was passed, so every
other tag-handling request inherited the API's default. That default is
documented as moving from v1 to v2, which would have shifted output with no
CLI change to attribute it to — and would have gone undetected by the cache,
since a request omitting the version hashes identically either side of the
flip.

One resolver feeds both the wire parameter and cache key field 10, so they
cannot disagree. An explicitly named version always wins, including v1;
requests without tag handling send nothing and keep their keys.
…rgets

The language-pair preflight compared the requested target verbatim against the
glossary's dictionaries, but dictionaries only ever name base languages while
--to accepts en-us, en-gb, pt-br and the rest. A de->en glossary therefore
failed locally for --to en-us, which the API accepts, making glossaries
unusable for the regional variants DeepL steers users towards.

Both sides now compare on their base language, so the check stays a nicer
error for pairs a glossary genuinely lacks without blocking valid work.
buildTranslationOptions mapped tagHandling but not tagHandlingVersion, so only
the text handler carried the flag. That was harmless while no version was sent,
but since the CLI pins v2 whenever tag handling is on, a file or directory
translation asked for v1 silently got v2 -- with no warning, because single-file
mode does not run warnIgnoredOptions.

The flag is now mapped in the shared base mapping every handler uses, and its
validation moves with it so both paths reject the same input.
…ng --from

TranslationService merges defaults.sourceLang, so a glossary request supplies
source_lang whether or not --from was typed. Rejecting on a missing flag alone
broke watch sessions and translations that had been working from config.

The guard now resolves the effective source language onto `from`, which also
gives the document path -- which merges no defaults of its own -- and the
glossary preflight the pair they need. Two related holes close with it:

- `--glossary` was tested for bare truthiness, and `[]` is truthy, so a caller
  passing an empty selection got a spurious error
- watch --dry-run returned before the guard, reporting a command as runnable
  that fails on the first file change

Watch also passes the pair to resolveGlossaryId now, so a glossary that does
not cover it fails at launch rather than once per file change. The text
handler's duplicate tag-handling-version block goes with the shared mapping.
…ges the text

The cache key carried tagHandling and the glossary selection but not the
translation memory, the XML tag parameters, or preserveFormatting -- so a plain
request and the same request with --translation-memory, a different
--ignore-tags, or --preserve-formatting collided and the cache served the wrong
translation while reporting cached: true.

preserveFormatting was excluded on the grounds that it does not affect output,
but preserve_formatting suppresses the sentence-boundary punctuation and case
correction, which shows up in the text.
The union was a fourth hand-maintained copy of the language list and had already
fallen four codes behind the snapshot it is supposed to describe: de-ch, de-de,
fr-ca and fr-fr -- the very codes the /v3/languages migration was written to
add. Neither generate:languages nor check:languages touched it, and it is
published, so `deepl config set defaults.targetLangs de-de` succeeded at runtime
while DeepLConfig could not type the config the CLI had just written.

ENTRIES is now generated `as const satisfies readonly LanguageEntry[]` and
Language derives from its codes, the same way WriteLanguage already derived from
WRITE_TARGET_LANGUAGES. Regenerating the snapshot widens both.

The registry's own lookups keep the interface rather than 125 literal types, and
the suite's tier counts are compared against the snapshot instead of written
out, so the documented release step no longer turns the suite red.
Three ways the generator could write a snapshot that breaks the CLI without
saying anything:

- an empty write list collapses the WriteLanguage union to never, so every
  --lang is rejected while the error names no valid option at all
- tiers come solely from features.glossary, so a matrix that stopped reporting
  it would retier all 125 languages as extended and make --formality and
  --glossary unusable everywhere
- --check compared quoted lowercase codes only, so the ten renamed display
  names of c955ca2 would have been reported as "formatting only"

Also fetches both resources together instead of failing fast on the first, so a
key that cannot read resource=write no longer blocks regenerating the
translation list it can read, and reports both failures at once. The rendering
is exported so the snapshot can be re-rendered from the data it already holds
when only the template changes.
The listing keeps snapshot entries the API response omitted, so those rows had
no feature data -- and every one of them was rendered as the positive claim
"none". With a response covering a handful of languages, --features reported
over a hundred languages as supporting nothing. Those rows also made every
feature look non-uniform, so features shared by all the described languages
became columns of repeated values instead of the single footer note the feature
was built around.

A language the response did not describe now reads as unknown, only described
languages decide what varies, and the shared-feature note says "languages with
reported features" when some rows have none, so it does not speak for them.

Two smaller lies with it:

- a feature reported without a `status` rendered as literal "undefined"; the
  enum is open and an absent status still means the feature is there
- supportsFormality was asserted false for a language whose features the
  response never mentioned, turning on the "[F] = supports formality" legend
  with no [F] anywhere to explain it

Also fetches /v3/languages once per client: both roles are filtered out of the
same payload, so `deepl languages` was making the identical full-list request
twice.
…insensitively

Failing a session that ends with a target untranslated was the right call, but
two things made it worse than the silence it replaced.

Target updates were matched against the exact requested spelling, and the
requested set uses casings like zh-HANS and en-GB. A server echoing another
canonicalization had its translation dropped by the existing `if (target)`
guard, and the new check then reported that target as missing -- turning a
cosmetic mismatch into a failed session. Lookups are keyed lowercase now.

A single missing target also discarded the source transcript and every
successful translation, after the audio had been transcribed and billed. The
failure now carries what did arrive, and the command prints it to stderr before
exiting non-zero, so nothing has to be re-streamed to see it.
Duration-billed products fell back to `apiKeyUnitCount` for the account column
because live responses omit `unit_count` for them -- so `deepl usage` printed
the same number twice, as though the key accounted for all voice usage on the
account. The account-wide figure the response does carry, `account_unit_count`,
was neither typed nor parsed.

It is parsed now, and when no account-wide figure is present the row says
"(API key)" rather than inventing a total.
sjsyrek and others added 23 commits August 9, 2026 22:42
…yers

Comments-only. The invariants a reader cannot recover from the code stay --
why POST is excluded from the replay set, why an empty Retry-After needs its
own check when Number('') is 0, why classification is idempotent, why the
XDG path priority is what it is -- while the story of the defect each guard
was written against goes, since the commit log and CHANGELOG already hold it.

Two docblocks were attached to the wrong declaration: the GET /v3/languages
contract sat above the private `translateLanguages` field with a second
docblock beneath it, and is now on `getSupportedLanguages`.

Also drops a crop of pure restatement in cache.ts and config.ts
(`/** Get cache statistics */`, `/** Enable cache */`, `// Validate boolean
fields`) that repeats the signature on the next line.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…histories

Comments-only. The format quirks a reader cannot deduce from the code all stay,
rewritten in the present tense: why `r` must remain in the PO escape set, why
adjacent PO string literals cannot be read as one, why a blank line is only a
convention between entries, why U+2028 cannot be escaped the way TOML escapes
it, why `#~` blocks belong to no entry, and why a dotted TOML key must not be
excluded from the entry pattern.

Two things the task called out are now marked explicitly rather than left to
inference:

  * `PoEntry.rawLines` is written for every line and never read back. It is
    left in place on purpose, and now says so, because removing it looks like
    cleanup.
  * the second entry scanner in `reconstruct` must stay separate from
    `parseEntries` -- one yields decoded entries, the other rewrites the file
    line by line so untouched lines survive byte-for-byte.

`extractTranslations`' docblock also now states the half that was implicit: an
empty msgstr means "not translated yet", the opposite of what an empty value
means in a monolingual format. It, and the CDATA-breakout docblock in
android-xml.ts, were sitting above the wrong function behind a second docblock.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Comments-only, and the largest share of it: every guard in the sync engine
arrived with a paragraph about the failure it prevents, and most of those
paragraphs outlived their moment.

Kept, because the code cannot show them: why ENOENT alone means absent and a
two-step exists-then-read cannot; why a size comparison precedes a content
comparison; why the target read is capped when only the source used to be; why
staleness is judged per locale rather than from the whole lock entry; why a
backup is left on disk when a run did not finish, and why deleting it on age
alone would destroy the copy a recovery run needs; why the destination-trust
gate exempts no loopback address; why a glossary term is normalized the way the
TSV round trip normalizes it.

Two corrections rather than trims:

  * acquireSyncProcessLock's comment described an "O_EXCL create". The pidfile
    is staged at a private path and linked into place -- `fs.linkSync`, as the
    docblock 60 lines above it explains -- so the comment named a mechanism the
    code does not use.
  * a `// Fix SPEC-02:` marker is gone. A ticket reference belongs in the commit
    message.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Comments-only. The bulk of this is a long tail of comments that label the next
statement rather than explain it -- `// Validate input`, `// Upload document`,
`// Poll for completion`, `// Start watching`, `// Add the last field` -- which a
reader gets from the call itself.

What stays is the reasoning: why an empty source value is filled in without a
request and must not leave a null slot; why each deduplicated index gets its own
result copy; why the cacheData property order is fixed; why the cache key must
carry the resolved endpoint; why a debounce timer is dropped only while it is
still the current one; why at most one translation per path may be in flight.

Two comments were replaced by what they were gesturing at: the symlink note now
says `safeReadFile` rejects a symlinked input rather than "(with symlink
security check)", and the file-size note says the document is sized before the
read so an oversize one is never resident.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…eason

Comments-only. Two kinds went. The first is restatement, thickest in the
commander wiring and the command classes: `// Get __dirname equivalent in ESM`,
`// Save to config`, `// Create abort controller for cancellation`,
`/** Truncate text for preview */`, and a `// Get target language (will throw
...)` repeated verbatim above four identical calls.

The second is the defect story attached to a guard. The rules those guards
enforce stay: that the JSON error envelope goes to stdout because stderr already
carries warnings from the CLI and from Node itself; that a lockfile arrives with
a clone and its keys are untrusted input; that an auto-commit staging nothing is
not a session failure, asked as a diff rather than by matching git's wording,
which a localized git translates; that `--frozen` must read the target and not
only the lockfile.

Also drops a `docs/API.md:2939` line-number citation, which rots on the next
edit to that file, in favour of naming the document.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…he measurements

Comments-only. These modules encode rules about hostile or awkward input, and
the rules stay: that an extglob repetition wrapping an unbounded wildcard gives
picomatch a nested quantifier; that a V8 out-of-memory abort is not catchable so
a pattern must be bounded before fast-glob sees it; that the redaction floor
exists because the match has no token boundary; that terminal control sequences
are neutralized only for a TTY, because redirected stdout is data; that a temp
sibling cannot be recognised by its name alone.

What went with them is the bench data from the session that found each one --
"~600MB at 20 groups", "28 ms at 20k comment lines, 109 ms at 40k", "about 50
SECONDS". The cap values they justify are documented where they are declared:
MAX_GLOB_EXPANSION still explains that it sits at the `braces` range limit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…eir suite

Header docblocks only -- no test body, title or assertion is touched. This repo
uses them deliberately to say what a suite is about, so length was not the test;
accuracy was. Of the 245 suites carrying one, 30 changed and the rest were read
and left alone.

Three carried a bead ID (`cli-8kr2.59`, `.60`, `.42`) and one carried `sync-qsn`.
Those now name the behaviour the suite pins, which is what a reader needed from
the reference.

Four made a claim the suite contradicts:

  * voice-stream-session said it covers "SIGINT handling"; the suite asserts the
    session registers no SIGINT listener at all.
  * cli-document-translation and cli-structured-file-translate each claimed CLI-
    level coverage. Neither spawns a subprocess; both point at the e2e suite that
    does.
  * cli-sync-init said the JSON error envelope goes to stderr. It goes to stdout.

The rest had a past-session defect story where the invariant belonged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
`grep -in 'breaking|migrat'` over README.md, docs/API.md, docs/SYNC.md and
docs/TROUBLESHOOTING.md returned zero hits against a release with five BREAKING
changes and 53 Changed entries. The CHANGELOG has all of it, but nobody reads a
changelog to find out why their script broke.

docs/MIGRATION.md is organised by what breaks in a caller's setup rather than by
command: requirements, removed flags and config keys, exit codes that moved,
machine-readable output moving to stdout, output that scripts parse, files written
to disk, tagged output, and the published TypeScript types. Each item has a
before/after, and the checklist at the end is the short version. Linked from the
README's Documentation list, its table of contents, and a short "Upgrading from
1.x" section under Installation, which is where someone upgrading actually lands.

Two corrections came out of verifying the claims by execution rather than trusting
the CHANGELOG:

  * an unknown option exits **6**, not 1. The BREAKING entry for the removed
    `sync init --source-lang` / `--target-langs` aliases predates the parse-error
    remapping later in this same release, which moved subcommand parse errors from
    1 to 6; that entry is corrected here too.
  * language codes are lowercase for `glossary` as well. An earlier entry says
    `glossary create`/`show` render them uppercase, which d15bc89 superseded --
    glossary.ts lowercases at display time -- so the guide documents the sweep and
    not the exception.

Also verified by execution: a retired `tms.*` key exits 7 with the replacement in
its suggestion, and `sync --force` without `--yes` exits 6 on closed stdin. The
Language union went 121 -> 125 with nothing removed (`de-ch`, `de-de`, `fr-ca`,
`fr-fr` added), while `WriteLanguage` did lose members to lowercasing, which is why
the guide calls the type change out.

docs/MIGRATION.md is deliberately absent from the documented-surface test's DOCS
list: it is the one document that must name flags this CLI no longer accepts, so
those checks would fail it by design. The DOCS array now says so.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…sweep

Two Unreleased entries disagreed about the same output. The `create`/`show` entry
claimed both commands render language codes uppercase; the later sweep entry states
codes are lowercase everywhere and names `glossary show` among the three casings it
replaced. The sweep is what ships -- verified against the code, not the log:
`normalizeGlossaryInfo` lowercases `source_lang`/`target_langs`, and
`formatGlossaryInfo` lowercases each dictionary pair -- so the uppercase clause is
what has to go.

The entry keeps its two accurate claims, both re-checked: the timestamp is rendered
through `toISOString()`, and the create success line goes through `Logger.output`,
which is stdout. It now defers to the sweep on casing rather than restating it.

The intermediate state is deliberately not described. Nothing in this release has
shipped, so no user ever saw codes rendered uppercase, and a changelog entry about a
casing that existed only between two unreleased commits is development narration.

`de-CH`/`de-DE`/`fr-CA`/`fr-FR` are left mixed-case in the unusable-languages entry:
that entry is about codes the API accepts and returns, which the sweep explicitly
exempts from the display rule, and input is case-insensitive either way.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
The Unreleased section had grown to 692 lines and 322 KB -- entries running 150-400
words that carried the defect narrative, the rationale for the chosen fix, live-API
verification notes, measurements, rejected alternatives and reassurances about what
did not change. That is a commit message, not a changelog entry. The already-released
1.1.0 and 1.2.0 sections are one to three sentences per entry; this restores that.

Now 291 lines and 137 KB: a 65% reduction in bytes, with 340 bullets merged to 206.
Every distinct user-facing fact survives -- flag names, config keys, JSON field names,
skip reasons, exit codes, renamed things, changed defaults. What went is the narrative
around them. Verified mechanically rather than by eye: of 374 distinct backticked
identifiers in the original, the only three absent are `totalChecked`, `no_matches` and
`pipe_pluralization`, all pre-existing surfaces that did not change in 2.0.0 and were
documented gaps instead (now fixed in SYNC.md and API.md).

Breaking changes are clustered at the top of Changed and prefixed `BREAKING -- scope`,
ordered by blast radius. For a MAJOR release the exit-code and stdout/stderr moves are
the headline, and they were buried mid-way through a 100-line list.

Two structural defects fixed. The `--break-lock` entry sat above `### Added` in no
category at all; it is now under Added. And the six test-infrastructure entries are
compressed to the two with user-visible effect -- the property suite and the credential
isolation -- since a changelog is for users of the CLI. Verified there are now zero
bullets outside a category and zero non-standard categories anywhere in the file.

Corrected one claim rather than carrying it over: `sync validate` was said to exit 8 on
a PO or XLIFF project holding untranslated keys. Measured, such a project checks zero
pairs and exits 0 -- an empty msgstr is not a source/translation pair. The real change
is that validate reads the real msgstr/<target> instead of comparing the source against
itself, so a translation carrying a dropped placeholder now exits 8 where it passed.

Also dropped the "Node < 22.5.0 degrades to running uncached" reassurance from the
better-sqlite3 entry: in a release that hard-floors the CLI at Node 24 and exits 6
below it, naming 22.5.0 reads as though Node 22 works.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…repo links

Audited every claim against the built CLI. The output samples were the worst of it --
several showed lines the CLI has never printed, and in three cases a correct sample sat
in the same code block as a wrong one.

Measured and corrected: `auth show` prints one masked line, not a `Status: Valid` line
and not an `Account type:` line (neither string exists in src/). `config set` prints
`✓ Set <key> = <raw value>`, echoing the value as typed -- not
`✓ Configuration updated:` with the value re-rendered as a JSON array, while the
`tms.allowedServers` example three lines below already had it right. `glossary create`
prints a fixed success line followed by the full info block, so the name and ID are on
their own lines and `Created:` was missing. `glossary delete` prints
`✓ Glossary deleted successfully` and does not interpolate the name. `detect --format
json` is pretty-printed, not one line.

Two blockers. `--target` is not a flag -- it is `--target-lang`, named correctly in the
adjacent example and twice more, so the two prose mentions were the outliers; the
documented-surface test cannot catch them because it only reads flags off `deepl ` lines.
And the quiet-mode section promised "errors and critical warnings" are always shown while
recommending `--quiet` for CI: `Logger.warn` is gated on quiet exactly like `info`, so
every warning is suppressed -- including the notice that `--api-url` is sending your API
key to a host that is not DeepL. That section also used `API Key: ...` to illustrate what
quiet suppresses, which goes through `Logger.output` and is never suppressed, and showed
the untranslated input as normal mode's output, teaching that quiet mode is what makes
the translation appear.

The Authentication section taught `auth set-key YOUR_API_KEY` as the way to set a key,
which the CLI warns about as deprecated and which Quick Start already flags. It now leads
with `--from-stdin`, documents `--no-verify`, and keeps the argument form with its reason.

Two gaps a script author would hit: `--format table` falls back to plain `[lang] text`
off a TTY, and under `--format json` a failing command writes its error envelope to
stdout -- the v2 change most relevant to the CI gate the README teaches, and absent from
the only place the file shows JSON shapes.

Repo links are now absolute GitHub URLs. npmjs.com resolves relative links against the
registry page, so all 26 `./docs/...`, `./examples/...`, `./CHANGELOG.md` and `./CLAUDE.md`
links 404 on the page a 1.x user lands on after `npm install -g @deepl/cli` -- including
the migration guide inside the "Upgrading from 1.x" section.

Also: the architecture block put TmsClient in the API layer (it is src/sync/tms-client.ts)
and called the cache generically SQLite where the Node 24 floor comes from `node:sqlite`;
TOC nesting put two config sections under Global Options and filed Command Suggestions
under Information; the `correct` flag list read as exhaustive while omitting four flags,
one used in the example above it; watch's nested output layout was undocumented; and the
Homebrew note promised a tap "shortly after the first npm release".

Verified: 125 languages = core 32 + regional 11 + extended 82, the 14 Write languages,
every documented default (timeout 30000, retries 3, concurrency 5, debounce 500ms,
100 KiB, batch 50, 30 MB, glossary max 5), and all 39 TOC anchors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
The flag surface was clean -- no documented-but-nonexistent flags, and `sync init
--format` was the only undocumented one across all 72 command nodes. The damage was in
the exit-code appendix and in shapes the file labels as stable contracts.

The appendix contradicted itself on voice: exit 6 listed "unsupported plan (pre-flight
check)" while its own section 9 and voice-client.ts:171 both say VoiceError, exit 9. The
exit-6 voice sites are language and content-type validation and the five-target cap.

Exit 8 was wrong in both directions for `sync validate`. It named "unbalanced HTML tags"
as a trigger -- `html-tags` is warn severity, measured: a run whose only issue is a lost
`<b>` exits 0 -- and omitted `icu-brackets` and `icu-structure`, which are error severity
and do exit 8. A CI gate built on that line does not fire on lost markup and fires
unexpectedly on ICU damage. The section also promised a soft exit for both check-style
commands; only `write --check` sets `process.exitCode`, while register-sync-validate.ts:91
calls `process.exit(8)` outright, so cleanup does not run.

Three "stable within a major version" shapes omitted fields the CLI always emits, which
is the worst kind of error in a block a consumer copies to build a parser. `sync status`
was missing `unwritten`, `needsReview` and `unwrittenByLocale` -- contradicted by the
file's own prose 76 lines later. `sync pull` was missing `replaced`, the one number that
says whether the pull overwrote local edits, and `dryRun`. `sync audit` was missing
`missingTargets`, which says which locales were silently excluded from the comparison.

The `sync pull` skip reasons were the wrong set: it listed two push-only reasons and
omitted nothing pull emits, while `push` documented no reasons at all despite its
envelope carrying them -- including `untranslated` and `needs_review`, both new in 2.0.0.
Each command's reasons are now listed separately, since the two sets are disjoint.

`sync validate --format json` had no documented shape at all, though every sibling
subcommand has one and this is the payload behind the exit-8 gate. Added, with the
`passed` semantics spelled out (`totalChecked` minus pairs carrying any issue, so a
warning-only pair is not "passed"), and its fabricated text sample -- per-locale grouping
and ✓/✗ counts the renderer has never produced -- replaced with real output.

Voice accepts a hard-coded allowlist, not the full language set: 39 targets and 30
sources, counted from voice.ts rather than taken on trust, with an unlisted code exiting 6
before any request. Nothing in the reference predicted that a valid `translate` target is
a hard failure on `voice`.

Smaller: the document-glossary note claimed "last-one-wins precedence", which two other
passages and the CLI's own help text explicitly deny; `--show-billed-characters --format
json` on a single target emits no character count at all, whatever the doc said about
metadata becoming JSON fields; the camelCase convention is a sync-section rule that three
commands outside it contradict; `--dry-run` still needs an API key; the exit-6 site list
omitted `--force` without `--yes`; and See Also pointed at neither the migration guide
nor troubleshooting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
The `sync validate` sample output was fabricated -- per-locale grouping, ✓/✗ marks,
per-locale valid counts and an "N issues across N locales" line, none of which exist in
the renderer. Replaced with measured output: one `ERROR`/`WARN` line per issue carrying
`<locale>/<key>`, then the error and warning counts. A reader writing a log grep against
the old block got nothing.

Corrected two claims about what validate checks. "Key count matches between source and
target files" is not a check and never was -- a target file that lost keys is reported by
`sync status` as `unwritten`, not here. And "HTML/XML tags balanced and matching" overstates
`checkHtmlTags`, which reports source tags absent from the translation at warn severity and
checks neither nesting nor balance, and does not report extra tags at all.

The `sync status --format json` contract omitted `unwritten`, `needsReview` and the
top-level `unwrittenByLocale` array, all always emitted, and gave a coverage formula
missing two of its five denominator terms -- so a consumer written from it mis-derives
coverage and loses two never-complete categories. The progress-bar legend predated both
categories too, contradicting the doc's own example output.

A documented instruction that silently does nothing: translation memory requires
`model_type: quality_optimized`, and the guide said to set it "at the same scope as
`translation_memory`, or the matching per-locale override". The per-locale value is on the
allowlist and validated, then never read -- sync-locale-translator.ts:309 takes the top
level only, while every sibling field on that object does the per-locale fallback. The
guide now says so; the behaviour is filed as cli-mq2y.

The exit-code table jumped 1 to 6, omitting 2, 3 and 5 -- two of which the same document
says sync emits, and the two most common transient failures a `case $?` would need. Every
code it did list was correct and agrees with API.md and exit-codes.ts.

`--break-lock` was explained in prose but absent from all three option tables of the
commands that accept it, and it is new 2.0 surface; `--yes` was only alluded to inside the
`--force` cell. `bak_sweep_max_age_seconds` is live, validated and read in two places but
had no schema row, while its default was documented as prose elsewhere. Two of the nine
push/pull skip reasons -- `no_matches` and `pipe_pluralization` -- appear in CLI output and
were never explained.

Also: `--frozen` is sold as making no API calls, which is true, but it still needs a key
and exits 2 without one -- so the documented pull-request recipe fails on a fork, where
secrets are empty; `sync init --path` metavariable disagreed with the CLI; the removed
`tms.*` note said the keys were "listed in this table" after they were correctly removed
from it; and two `#deepl-sync-lock` anchors never resolved (the heading slug drops the dot).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…d sync-lock

The "Style or tone not applied" section troubleshot a `--formality` flag `deepl write`
does not have. It told the reader to "verify you are using valid formality values" and
sent them to `deepl write --help`, which will never list one -- formality belongs to
`translate` and `voice`; on Write the equivalents are `--style` and `--tone`. Rewritten
around the flags that exist, with the `prefer_*` fallback as the actionable remedy.

Exit 8 was scoped to `write --check` alone, in the reference table, the section heading and
the TOC. `correct --check` carries the same flag and `sync validate` exits 8 on any
error-severity issue, so a CI author reading only this file treats an 8 from `sync validate`
as an unrecognised code. Three documents gave three different scopes for the same code.

Added a section for the process lock. `deepl sync`, `sync pull` and `sync resolve` take a
per-project lock and exit 7 while it is held -- new 2.0 behaviour, with `--break-lock` as
the escape -- and the only exit-7 entry here was "invalid config file", whose remedies
(`config list`, delete `config.json`) are all wrong for that cause.

Smaller: "Reset a specific setting" was illustrated with `config set`, the command that
sets one, with no mention of `config reset`; and the supported-format list omitted `.md`,
`.json`, `.yaml` and `.yml`, so a reader whose "unsupported format" error is about a
markdown or JSON file concludes the format is not supported and stops.

Verified while here: no `better-sqlite3`, `NODE_MODULE_VERSION` or `npm rebuild` advice
survives anywhere, the Node-version message quotes node-version-check.ts accurately, and
every other remedy command exists on the real CLI surface.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…rmat changes

The guide's own opening says the exit-code section is the change most likely to alter what
a CI pipeline decides, and unlike a removed flag it does not announce itself. The table was
missing the broadest-reach move in the release: every parse error -- unknown subcommand,
unknown option, out-of-range choice, missing argument -- went from 1 to 6. Measured both.
Any wrapper branching on exit 1 as "the CLI crashed" silently changes meaning, and it is
also the code the removed flags above it report, so the table asserted 6 without ever
stating the rule that produces it.

Eight further moves were absent, three of them turning a previous exit 0 into a failure,
plus the Ctrl-C 130 that no document records. The 6 to 5 timeout move needed a row of its
own because it moves a condition into the retriable set: a pipeline that gave up on 6 will
now retry it.

Corrected a row rather than adding to it: `sync validate` was said to exit 8 on a PO or
XLIFF project with untranslated keys. Measured, such a project checks zero pairs and exits
0 -- an empty msgstr is not a source/translation pair, so those keys never reach the checks
at all. The real condition is a translation carrying an error the check previously could not
see, because it compared the source against itself for bilingual formats.

Added the config-validation tightening, which was missing entirely and is a load-time
refusal, so it fails every sync subcommand rather than one run: BCP-47 locale validation,
`.git`/`.github` target segments, target paths beginning with `-`, globs escaping the project
root, and `--locale` values absent from `target_locales`. The nastiest is config discovery
now stopping at the repository boundary -- a monorepo whose config sat one directory above
the repo root gets "no sync config" rather than a validation error.

Added the two format-level refusals that can turn a working project into a refused one: YAML
aliases are no longer expanded, so an anchor-based catalog yields a different key set, and an
Android XML translation containing `]]>` is withheld rather than written into a CDATA section.
And `sync resolve` now applies its documented `translated_at` tie-break, so a team that has
been resolving conflicts under 1.x -- silently always keeping local -- sees the same conflict
resolve the other way.

Also: "Warnings stay on stderr, so a check for the presence of stderr output still works" is
no longer safe advice -- 2.0.0 adds three unconditional warnings 1.x did not emit, the
non-DeepL endpoint notice among them, which is the diagnostic most likely to break a
merged-stream parse. The `WriteLanguage` header said four regional codes above a table of
five, the `tms:` example showed two of the three removed keys under "remove both", and the
CHANGELOG link is now absolute since the changelog is not in the tarball.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…ling default

Three shipped CI recipes could not run. `21-cicd-integration.sh` pinned
`node-version: '20'` and `23-sync-ci.sh` used `image: node:20`, against
`engines.node >= 24.0.0` and a hard startup gate that exits 6 below Node 24 -- so a user
who copies either gets `deepl requires Node.js >= 24` on every invocation. The two GitHub
Actions recipes in `23-sync-ci.sh` had no `setup-node` step at all, so they worked only if
the runner's default Node happened to be new enough. docs/SYNC.md already pinned 24 in the
equivalent recipes; the examples are the copy-paste surface and must not be weaker.

`39-advanced-translate.sh` told the reader v1 is the default tag handling. v2 is:
DEFAULT_TAG_HANDLING_VERSION is 'v2' and is sent on every `--tag-handling` request that does
not name a version. Corrected, and the later summary line now states which is the default
rather than leaving it open. Checked every other script for the same claim stated differently
-- no other script mentions the flag.

Two retired package names: a fixture in `12-cost-transparency.sh` and a prose line in
`36-completion.sh`. The `deepl-cli` occurrences in `28`, `29` and `30` are config and cache
directory paths, which keep that name, and are deliberately left.

Worth noting for later: the docs suite checks every `deepl` invocation in README, API.md,
SYNC.md and TROUBLESHOOTING.md against the real CLI surface, but `examples/*.sh` are outside
its scope -- which is why the tag-handling default drifted here and nothing caught it.
Verified by hand this time: no script uses a removed flag, all 40 are registered in
run-all.sh and examples/README.md, and numbering is contiguous.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…nd the file index

The release checklist in CLAUDE.md never published the package -- the one thing 2.0.0
exists to do. Its five steps produce a tag and a GitHub Release and stop; release.yml
carries no `npm publish` step or NPM_TOKEN by design, saying publishing happens from
GitLab. So a lead following it verbatim ships a v2.0.0 tag while `npm install -g @deepl/cli`,
the command MIGRATION.md and the README tell every 1.x user to run, keeps 404ing. The
step now says the tag alone does not make the package installable, and names the
verification. It stops short of naming the GitLab job: there is no .gitlab-ci.yml in this
checkout, so that has to be filled in by someone who can read it.

Three other gaps in the same list: nothing refreshed SECURITY.md's supported-versions
table or checked MIGRATION.md against the section just dated, both version-scoped
documents; nothing reviewed the tarball, which is where a `files` mistake surfaces; and
`git push` named no remote in a checkout with three.

Step 1 also justified itself with a false premise -- that a stale Write list breaks
`write`/`correct` because they reject unknown codes locally. They do not: a code shaped
like a language tag but absent from the snapshot is sent to the API with a warning, which
is measurable (`--lang xx-yy` warns and defers; only malformed input is rejected). What
actually goes stale is offline `deepl languages`, the derived tiers, and the bundled-options
list in error messages. Harmless to follow, but the wrong reason, and misleading to whoever
edits that step next.

CONTRIBUTING's "full check suite" named four of the six gates CI runs, omitting
`format:check` and `check-deps`, so a contributor who ran the documented command could still
fail CI. Its PR checklist had rows for CHANGELOG, README, API.md and run-all.sh but none for
docs/MIGRATION.md, now a maintained document any breaking change must touch.

CLAUDE.md's architecture block omitted three services and placed TmsClient in the API layer
-- it is src/sync/tms-client.ts, which the block's own next line puts under the sync engine
-- and Key Project Files listed two of the seven documents a contributor needs, missing the
migration guide on the release whose headline deliverable it is.

The bug-report template offered "source, npm link, other" as install methods, written for a
pre-publish world: the published path is not among them, so every 2.x reporter picks "other"
-- and install method is what tells a maintainer whether a report is a packaging bug.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Set via `npm version 2.0.0 --no-git-tag-version`, so package.json and the lockfile move
together. This was the one hard publish blocker: release.yml refuses to publish when the tag
and package.json disagree, so a v2.0.0 tag hard-failed against 1.2.0 -- and its changelog
extraction matches a `## [X.Y.Z]` heading, which the previous commit supplies. SECURITY.md
and docs/API.md were already written for 2.0.0, so the tree was inconsistent in two
directions until now. Verified: `npm pack --dry-run` reports 2.0.0, and `deepl --version`
agrees.

Ship `docs/` in the tarball. README.md is the npmjs.com landing page and carried 13 relative
links into `docs/`, all of which 404 there, and the file was not in the installed package
either -- so the migration guide a 1.x user is pointed to was unreachable by both routes. The
README links are now absolute (previous commit), which fixes the browser; shipping docs/ fixes
the `node_modules` reader and makes MIGRATION.md available offline. Costs 98 KB packed,
263 to 361 KB. CHANGELOG.md is deliberately not shipped -- 388 KB for a link that the
absolute URL already fixes.

`repository.url` is now npm's canonical `git+https://...git` form. The manifest test pinned the
bare form; its stated guarantee is the DeepL org rather than the URL shape, so it moves to the
canonical value and stays an exact pin rather than being loosened.

The `files` map keeps its three `!dist/**` exclusions even though the build no longer emits
maps or writes tsbuildinfo into dist. They are dead today but cheap insurance against a
tsconfig change re-enabling either, and a test asserts two of them.

Keywords gained the release's headline capabilities -- Write, glossaries, translation memory,
gettext, XLIFF, continuous localization -- none of which were discoverable for a package that
has never been on the registry.

Also corrects the SyncJsonOutput docblock, which promised its fields stable "across 1.x" in the
2.0.0 tree; it is the only package-version reference in src/, and the shape gained fields this
release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
The recipe fix is user-facing even though examples/ is not in the tarball: a reader who
copied the GitHub Actions or GitLab CI block got a job that exited 6 on every run. Held to
the same brevity as the rest of the condensed section, and placed under Fixed beside the
existing examples entry.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
translation.locale_overrides.<locale>.model_type 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. The validator's own error message named that inert scope as
the remedy, so the CLI walked users into a key with no effect.

A locale configuring translation memory per locale therefore got requests the
TM could not be applied to -- translation memory requires quality_optimized --
silently, and was billed for them.

The override now resolves exactly like every sibling on the same options
object (formality, translation_memory_threshold, custom_instructions,
style_id): the per-locale value wins, otherwise the top-level one applies.
SyncLocaleOverrides declares the field. As with those siblings, a per-locale
override consequently also takes precedence over --model-type, which writes
the top-level setting.

Also closes the mirror-image gap in the pairing check. It only inspected a
model_type written inside the same override, so the opposite inheritance
direction -- translation_memory set per locale, model_type inherited from the
top level -- was accepted and sent to the API with the same hazard. The check
now evaluates the effective value for that locale and names where the
offending one came from, so such a config exits 7 before any API call.
…perimental

engines.node said >=24.0.0 on the claim that Node 24 is the first release where
node:sqlite is non-experimental. Measured across 24.0.0, 24.5.0, 24.9.0,
24.12.0, 24.13.0, 24.14.0, 24.15.0 and 24.18.x: every version below 24.15.0
emits "ExperimentalWarning: SQLite is an experimental feature and might change
at any time". 24.15.0 is the first clean one.

So the declared floor advertised a runtime that writes a warning to stderr on
every cache-backed command. That is not cosmetic: as of 2.0.0 a failing command
under --format json writes its envelope to stdout, and callers that merge
stderr into stdout and parse the result get a stray line.

The startup guard was major-only, so 24.0.0 through 24.14.0 passed it and then
produced exactly the ExperimentalWarning the guard exists to prevent -- its
docstring promised coverage the comparison did not implement. The check is now
major and minor aware, and reports the full minimum version.

Behaviour preserved at the edges: an unparseable version still fails open, and
an older major given without a minor (22) is still rejected rather than falling
through the new minor comparison.

BREAKING CHANGE: engines.node moves from >=24.0.0 to >=24.15.0. Node 24.0.0
through 24.14.0 are no longer supported and now exit 6 at startup.
Nothing in CI packed or ran the published artifact. The suites execute from the
working tree, and bin has its own module graph, so a broken programmatic entry
point ships while every test passes -- which is how a package entry that threw
ERR_UNSUPPORTED_DIR_IMPORT reached a release once already.

The new job packs the tarball, installs it globally to run deepl --version,
then installs it into a throwaway consumer package and imports @deepl/cli. The
consumer step is separate because Node does not resolve bare specifiers out of
the global prefix. It asserts the entry exports something rather than merely
resolving, so an empty emitted module fails too.

The matrix also pins 24.15.0 alongside 24. Bare 24 resolves to the latest 24.x,
so the engines.node floor was never exercised.

release.yml gains the direction of the mirror. Its note correctly said
publishing happens from GitLab but not which way the code flows, and the
changelog had it backwards. GitHub is upstream, GitLab pull-mirrors this
repository and publishes from there, so a tag pushed here reaches the pipeline
on its own and nobody should hand-push to GitLab.
…paths

Changelog entries for the per-locale model_type fixes and the packaged-artifact
CI job, and the 2.0.0 heading moves to its actual release date.

The Node requirement is corrected wherever it was stated -- changelog, README,
migration guide, troubleshooting guide and contributor guide -- including why
the floor is a minor rather than a major, since "Node 24 or later" is no longer
sufficient advice. The troubleshooting entry keeps nvm install 24 as the remedy,
which lands past the floor.

Homebrew and npm are now described as available rather than pending, and the
README leads with brew install deepl/tap/deepl: it is the only Node-free path,
because the formula brings its own Node, and it hides the package scope. The
changelog's claim that GitLab mirrors to GitHub is corrected to the actual
direction.
Comment thread tests/unit/sync/sync-process-lock.test.ts Dismissed
Comment thread src/utils/logger.ts Dismissed
Comment thread src/cli/commands/completion.ts Fixed
Comment thread src/utils/logger.ts Dismissed
Comment thread src/sync/sync-context.ts Dismissed
Comment thread tests/e2e/cli-sync-tms.e2e.test.ts Dismissed
Comment thread tests/e2e/cli-stdout-routing.e2e.test.ts Dismissed
Comment thread tests/e2e/cli-stdin-stdout.e2e.test.ts Dismissed
Comment thread tests/e2e/cli-hooks.e2e.test.ts Dismissed
Comment thread tests/e2e/cli-hooks.e2e.test.ts Dismissed
Reclaiming a pidfile proven stale renames it aside and then confirms the
captured file is the one that was inspected, so a sync whose staleness verdict
was overtaken by a winner puts the winner's live pidfile back instead of
deleting it. That confirmation compared inode and device only.

An inode number is reused once the file holding it is unlinked, and a winner
reclaims the lock by unlinking and re-creating in the same directory. On ext4
the freed inode is handed straight back, so the winner's live pidfile compared
equal to the stale one it replaced and was deleted -- leaving both runs
believing they held the lock, writing the same target files and the same
lockfile. This is the concurrent-writer hazard the lock exists to prevent.

Identity now also requires the recorded pid and startedAt to agree, which a
different holder cannot satisfy, and a null payload can only match a payload
that is still unparseable. The reclaim re-reads through inspectPidFile, so the
payload and identity still come from one descriptor.

Found by CI, not locally: APFS allocates inodes monotonically and never reuses
them, so the existing race test passed on macOS across three full-suite runs
and failed deterministically on both Linux matrix legs. The added test forces
the inode numbers to collide so the payload has to carry the verdict, which
makes the guard testable on any filesystem.
Inside fish single quotes only \\ and \' carry meaning, but the fish generator
escaped descriptions by replacing ' alone. A description ending in a backslash
therefore escaped its own closing quote and ran the rest of the generated
complete(1) line into the description.

One helper now escapes backslashes first and quotes second, used at all four
fish sites. Escaping in the other order would double the backslashes the first
pass adds. The bash and zsh generators use the POSIX '\'' form and are
unaffected.

Not reachable by untrusted input -- the descriptions are this repo's own
command and option metadata -- so this is a correctness fix for generated
output rather than a security fix. It also clears four CodeQL
js/incomplete-sanitization alerts.
Both under 2.0.0 Fixed. The lock entry states the filesystem dependence
explicitly, since the defect is invisible on APFS and deterministic on ext4.
…on claim

The header comment asserted that GitHub is upstream and GitLab pull-mirrors from
it. That arrangement is being reworked and is owned outside this repository, so
the claim is not ours to make and would go stale in a public file. The remaining
comments are removed with it rather than left as a partial commentary; the step
names already say what each step does, and the release procedure lives in
CLAUDE.md.

The changelog entry loses the same claim: it now says only that the workflow does
not publish to npm, that publishing runs from a separate GitLab pipeline, and
that a tag records the Release and nothing more -- all of which is true
regardless of how the two copies are kept in sync.

Behaviour is unchanged. Verified the file still parses and keeps its v* tag
trigger, contents: write permission, and all four steps.
The test built a cache with ttl: 40 -- milliseconds -- then asserted, after
writing 'd' and 'e', that reading them back returns a value. Expiry is a
Date.now() comparison made when a row is read, so that assertion required fewer
than 40ms of wall clock to pass between the write and the read. On a CI runner
sharing a machine with three other Jest workers, one scheduler or GC pause
exceeds that and the entry legitimately expires, so the test failed while the
cache behaved correctly. It failed exactly that way on both matrix legs.

This was the only assertion in the suite that needed time NOT to pass. The two
other short-ttl tests assert toBeNull() after a sleep, which a slow runner only
makes more certain.

The cache holds no timers of its own, so fake timers control it exactly:
advanceTimersByTime expires a, b and c on demand, and no time at all passes
between writing d/e and reading them, so they cannot expire. A null there now
means only what the test is named for -- the sweep evicted a fresh row.

Verified the timers are doing the work rather than the assertions being vacuous:
with the advance removed, get('a') returns its stored value instead of null, so
the advance is what expires the rows and no real time is leaking in. Real timers
are restored in a finally block.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants