release: v2.0.0 — publish as @deepl/cli, migrate the cache to node:sqlite - #115
Open
sjsyrek wants to merge 256 commits into
Open
release: v2.0.0 — publish as @deepl/cli, migrate the cache to node:sqlite#115sjsyrek wants to merge 256 commits into
sjsyrek wants to merge 256 commits into
Conversation
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.
…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.
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.
pkarolyi
approved these changes
Aug 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Releases 2.0.0 — the first public release of the DeepL CLI, published as the scoped
@deepl/cli. The translation cache moves from thebetter-sqlite3native addon to Node's built-innode: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
node:sqliteinstead ofbetter-sqlite3, so there is no native compilation and noERR_DLOPEN_FAILEDafter a Node upgrade.engines.nodeis>=24.15.0. The package ships as@deepl/cli; the command is stilldeepl. Existing cache databases are read in place, no migration.deepl correct(spelling and grammar without rewording);--glossaryrepeatable up to five, and now applied to document translation;deepl languages --features; JSON payloads for--check,--diffand--alternatives;sync pull --dry-run;--break-lock; global--timeoutand--max-retries.--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.Packaged artifactjob packs the tarball, installs it, and imports the package entry; the test matrix now pins theengines.nodefloor alongside the latest Node 24.Backward Compatibility
❌ Breaking changes — this is a major release. Every one is enumerated in
CHANGELOG.mdand covered with before/after examples indocs/MIGRATION.md. The ones most likely to affect an existing pipeline:@deepl/clideeplcommand unchangedengines.node>=24.15.0--format jsonfailures go to stdout> out.jsonnow captures failures tootms:keys removed--enable-beta-languages,tms.auto_push/auto_pull/require_review✅ Preserved — the
deeplcommand name, the on-disk cache format, all wire parameters that are not display output, and text/table output byte-for-byte.Test Coverage
nockfor HTTP andDEEPL_CONFIG_DIRfor isolation.format:check,lint(--max-warnings 0),type-check,check-deps,test:coverage.Packaged artifactjob covers a class the suites structurally cannot: they run from the working tree, andbinhas 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:sqliteemitsExperimentalWarning: SQLite is an experimental featureon 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 jsonnow 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
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