Skip to content

Commit e08225a

Browse files
sjsyrekclaude
andauthored
release: v2.0.0 — publish as @deepl/cli, migrate the cache to node:sqlite (#115)
* feat(cli): apply --glossary to document translation 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. * docs: document glossary support for document translation 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. * fix(docs): add the required --from to every --glossary example 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. * docs(watch): note that --glossary needs --from /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. * fix(voice): fail when a session ends without a translation 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. * fix(watch): require --from when --glossary is used 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. * refactor(cli): display language codes in lowercase everywhere 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". * test(watch): recover the dropped fsevents add in the filesWatched test "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. * fix(languages): source formality from the v3 features matrix 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]. * feat(languages): add --features to show per-language feature support `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. * test(languages): cover --features end to end against the mock API 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. * docs(languages): document --features and the formality fix 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. * refactor(languages): generate the language list from the API 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. * fix(languages): let the API decide which language codes exist 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. * fix(languages): list languages the API offers but the snapshot predates 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. * docs(languages): document API-as-authority and the 125-language list 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. * test(languages): stop the regional-variant assertions depending on colour 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. * docs(changelog): note the ten display names the API differs on 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. * feat(translate): preflight a named glossary against the requested pair 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. * refactor(write): generate the Write target languages from the API 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. * feat(translate): pin tag_handling_version to v2 instead of the server 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. * fix(glossary): treat a base-language glossary as covering regional targets 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. * fix(translate): honour --tag-handling-version outside text mode 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. * fix(glossary): resolve the source language from config before requiring --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. * fix(services): key the translation cache on every parameter that changes 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. * refactor(types)!: derive the Language union from the generated snapshot 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. * fix(scripts): guard the language generator against silent bad output 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. * fix(languages): stop --features claiming knowledge it does not have 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. * fix(voice): keep partial transcripts and match echoed languages case-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. * fix(usage): stop reporting API-key usage as the account total 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. * fix(translate): stop repeating a doomed request and say when a code is deferred Since validation defers unknown-but-well-formed codes to the API, a two-letter typo now reaches it -- and a directory translation asked the same rejected question once per batch, spending a round trip each time to be told the same thing. An unsupported target_lang or source_lang is a property of the request, not of one batch, so the remaining batches fail without being sent. Errors specific to a batch still let the run continue. Local validation also used to be the thing that pointed at `deepl languages`, and deferring left the user with a bare "target_lang not supported" from the server. A code the bundled snapshot does not list now says so up front, before anything is sent or billed. * fix(write): defer an unknown Write language to the API instead of rejecting it The Write list is checked locally because it is small enough for the error to name every option -- but it is still a generated snapshot, and nothing in CI regenerates it. A language DeepL added was therefore unreachable until someone ran generate:languages, with no flag or env var to get past it. A code shaped like a language tag now goes to the API with a warning that still names the bundled set, so the user gets more information than the old hard failure gave, not less. Malformed input is still rejected locally. The generated file is also added to .prettierignore: check:languages compares raw text, so `npm run format` would have reported permanent drift. * fix(glossary): dedupe IDs, check coverage in sync, and validate before dry-run Six smaller defects around the multi-glossary work: - naming one glossary twice (or a name plus its own UUID) sent glossary_ids with a duplicate: it flipped the wire parameter away from glossary_id, minted a third cache key for an identical request, and spent two of the five slots - sync resolved its glossary without the language pair, so a glossary that does not cover it failed once per file instead of at startup, as the sibling translation-memory resolution already avoided - translate --dry-run reported a glossary command as runnable that the real run rejects for a missing --from - the document path never ran the extended-tier constraint check, so a glossary with an extended target uploaded the file and let the API refuse it, while the text path said so locally - `glossary info` printed raw dictionary languages under a normalized summary, so one glossary could show "EN → DE" beneath "Source language: en" - a coverage error listed every dictionary of a multilingual glossary on one line The glossary pair matrix also compares languages after normalization, so casing differing between the two role lists can no longer emit a self-pair. * fix(config): normalize language codes and warn about ones the snapshot lacks `config set defaults.sourceLang DE` was rejected while `translate --from DE` works, because the translate paths lowercase the flag first and the fallback pattern is lowercase-only -- so the one casing that is certainly valid was the one class of input still refused. Values are lowercased before validating and stored normalized. A code the bundled snapshot does not list is still accepted, since the snapshot can lag the API, but it now warns at the point of entry: a typo written to config otherwise failed on every later command with nothing pointing back at the config value that caused it. Also documents the repeatable --glossary in the flag table, adds the required --from to the last example that omitted it, and gives the api.ts import of ./common the extension its neighbour already had. * fix(api): compare glossary pair languages after normalization The v3 cross-product skipped the identity pair on raw casing, so a language spelled differently across the two role lists would emit a self-pair the API does not offer. * docs(changelog): record the review fixes and correct claims they invalidated Adds Fixed entries for the cache-key omissions, the per-batch retry of a rejected language, the voice usage figure, the glossary minors, the config-only source language, the --features overclaiming, config code normalization, the tag-handling version dropped outside text mode, and the generator guards; plus a Changed entry for the derived Language union. Five existing Unreleased claims were no longer true and are corrected in place rather than contradicted further down: - write no longer rejects every unknown code locally - the multipart glossary_ids encoding was described as live-verified; the live document endpoint answers any unresolvable glossary the same way regardless of encoding, so only parameter recognition was confirmed there - the glossary preflight compares base languages, so regional targets work - the shared-feature note is scoped when some languages have no data - formality comes from the v3 features matrix, not the registry * fix(languages): list the bundled snapshot in --format json without an API key The JSON path answered {"source":[],"target":[]} while text and table printed all 125 languages from the same snapshot, so the documented offline listing worked in two formats out of three. JSON falls back to the snapshot as the others do. * docs(comments): describe current behaviour instead of how it got there Comments across the language, glossary, voice, cache, usage and generator code narrated the development process -- what the code used to do, which bug a check exists because of, what an earlier version got wrong. They now state the behaviour and the constraint behind it. Two corrections came with the pass: the generated Write-language doc still said `write`/`correct` reject an unknown code locally, which is no longer what the code does, and the same text lives in the generator template, so the snapshot was re-rendered from it and verified byte-identical against the live API. * fix(package)!: give the ESM entry point resolvable specifiers `import '@deepl/cli'` failed with ERR_UNSUPPORTED_DIR_IMPORT: the package is `type: module`, but src/index.ts re-exported the directory './types' and src/types/config.ts imported './common' without an extension. Node resolves neither, so the programmatic API could not be loaded at all, and `nodenext` consumers saw no exported types -- `import type { Language }` was an error whatever the union held. The CLI itself was unaffected, which is why nothing caught it: `bin` points at dist/cli/index.js, whose module graph uses full specifiers throughout. The manifest suite now imports the built entry in a real Node ESM process (jest's CJS transform cannot) and fails on any extensionless relative specifier in the emitted entry chain. Verified by reverting the fix: 3 of the new assertions fail. * fix(scripts)!: validate the API response before writing it into TypeScript The generator interpolated `lang` and `name` from GET /v3/languages straight into a single-quoted TypeScript literal. `lang` was not escaped at all and `name` escaped only quotes, so a response field ending in a backslash -- or containing `' }] as const;` -- could close the literal and append arbitrary code to src/data/language-entries.ts, which the next build compiles and the test suite imports. The existing guards check the shape of the response, never the content of a field. Codes must now match the language-tag pattern, display names a conservative letter/mark/digit/punctuation set, and categories one of the three tiers; everything is quoted with escaping as well. Validation runs before grouping, because grouping filters by category and would otherwise drop an entry with an unrecognized one before it was ever checked. The main guard also compares argv[1] through realpathSync: Node resolves the ESM entry to its real path, so under a symlinked checkout both npm scripts exited 0 without doing anything -- including the release step that exists to stop the Write list going stale. * fix(cache): retire the translation entries the new key derivation orphans Adding preserveFormatting to the cache key changes every translation key, not only the keys of requests using the newly-hashed parameters: the service merges `defaults.preserveFormatting`, which defaults to true, so the field is always materialized. The design note and the CHANGELOG both claimed otherwise. Rather than walk the correctness fix back, the schema version is bumped so those rows are dropped on first open instead of lingering unreachable until their 30-day TTL, counting towards the size budget and `cache stats` in the meantime. The purge is scoped to the `translation:` namespace -- write and correct entries key the same way they always did and are still read in place, as the v2 notes promise. * fix(translate): extend the doomed-request abort past plain-text batches The short-circuit only covered .txt/.md, the files that were already grouped into batches. Everything else -- .json, .yaml, .html, .srt, .xliff -- goes through the per-file path, which still spent one round trip per file to collect the same rejection, so `translate ./locales --to xx` over 200 JSON files made 200 failing requests. That path now stops the same way, and the files it never sent are reported as skipped rather than as failures carrying another batch's error text. The classifier also gates on the error class before matching the message. A 5xx interpolates the upstream body into a NetworkError, so a gateway error quoting `target_lang` would have aborted a run that was otherwise succeeding. A refused key and an exhausted quota now abort too: they are as identical across items as a bad language code. * fix(voice): keep the salvaged transcripts visible, and match casing in the display Three holes in the partial-result path: - the salvage went through Logger.warn, which --quiet suppresses, while the TTY display was erased regardless -- so `voice … -q` on a TTY wiped the rendered transcripts and printed nothing in their place, for audio already billed. It goes through Logger.error now, which quiet mode does not touch, and the display is only cleared once there is something to print instead - --format json was ignored, so a consumer capturing stderr got prose - the live display still keyed target state by the requested spelling, the exact defect the session-level fix addressed: with `--to zh-HANS`, a `zh-Hans` echo left the row blank for the whole session Also corrects the voice `--glossary` row in docs/API.md, which had picked up translate's semantics: voice takes a single glossary, is not repeatable, and has no --from requirement. * fix(languages): stop --features contradicting its own footer A language the response described but which supports none of the discriminating features rendered as `none`, two lines above a footer crediting it with the features every described language shares. Only a language credited with nothing at all now reads `none`; the rest defer to the footer. Two more display defects with it: `--features` disabled the Formality column without always replacing it, so the flag could show strictly less than the plain listing, and the `?` cell for an undescribed language had no legend even though the text mode and the `[F]` marker both explain themselves. The `[F]` legend also keyed on `supportsFormality !== undefined`, so a language whose features were described without formality turned the legend on with nothing under it. * fix: five defects the second review pass turned up - **glossary**: deduplication kept a repeat's first position, which inverted the documented last-wins precedence -- `--glossary base --glossary override --glossary base` handed conflicts to `override` although the user put `base` last. A repeat now keeps its last position. - **sync**: the new startup coverage check required the top-level glossary to cover every target locale, including ones with their own `locale_overrides.<locale>.glossary`. That glossary never translates those locales, so a documented configuration failed before any file was touched. - **config**: normalization was wired into `set()` only, so a file written or edited with uppercase codes kept them, and `TranslationService` merges `defaults.sourceLang` verbatim -- `DE` in config and `--from de` keyed two cache entries for one request. The load path normalizes too. The unknown-code note is also scoped to the write path; shared with the loader it fired on every invocation, including `deepl --version`. - **translate**: document mode warned that `--model-type` and `--tag-handling*` would be ignored and then rejected the command over them, because the shared validation still saw them. They are dropped after the warning, so the mode ignores what it says it ignores. - **usage**: a duration-billed product could still render a character count as hours by scaling it, and a `null` count was reported as a real zero. Character counts are read only for `milliseconds` billing, where they carry the duration; non-finite values are treated as absent. * fix: report a failed directory run, and accept voice codes in any casing A directory translation where every file failed printed its failure list and exited 0, so a script or CI job read it as success. Since language validation defers unknown codes to the API, a plain --to typo takes that path: on 1.x it exited 6 locally. A run with no successes now exits 1 and a partial failure exits 12, matching sync. `voice` also demanded the exact mixed-case spelling of a regional code, so `--to en-gb` and `--to zh-hans` exited 6 while `--to en-GB` worked -- the lowercase form being the one `deepl languages` prints and every other command accepts. Codes are matched case-insensitively and canonicalized to what the Voice API expects. * test: restore the guards the relational counts removed Comparing the registry against the ENTRIES array it is built from proves the plumbing and nothing about the data: deleting 71 of 125 languages left the registry suite green. Floors well under today's counts, plus named codes from every tier, fail on a snapshot that lost most of its languages while still surviving any plausible upstream change -- which was the point of de-pinning the literal counts. Three more assertions that could not fail: - the batch-abort classifier had no direct test at all; three of its four patterns were unverified, and the error-class gate untested - the tiering heuristic for a language the snapshot does not list was asserted nowhere, so a regional variant could silently land in the wrong tier - two assertions passed on substrings that occur in unrelated words ('de' inside "code", 'glossary' inside the extended-languages header) The shared /v3/languages nock fixture also carried no `features` matrix, so through the real code every language in it tiered as extended with formality unreported -- the opposite of what those four languages support. * docs(changelog): repair the Unreleased structure and the claims that were false The Unreleased section had two `### Changed` and two `### Removed` headings, and eight entries under the first `### Removed` described features that exist and work: NO_PROXY support, `auth set-key --no-verify`, `check-deps`, the `t`/`w` aliases, the GitHub Release workflow, `--timeout`/`--max-retries`, `cached` in JSON output, and the Node<24 fast-fail. release.yml publishes this section verbatim as the release notes, so v2 would have announced eight live features as deleted. Categories are merged, one per release, in Keep a Changelog order. Three claims corrected rather than left to ship: - the cache entry scoped invalidation to "requests using any of these options"; it is every translation entry, because a preserveFormatting default is always merged. The schema bump that retires them is described instead - the tag-handling entry repeated the same scoping - "Input remains case-insensitive everywhere" was false for `voice`, which rejected the lowercase regional codes the CLI itself prints Also fixes three stale passages in docs/API.md: the `voice --glossary` row had picked up translate's repeatable/`--from` semantics, the `write` reference still said an unknown code is rejected locally, and the `usage` reference still documented the removed Speech-to-Text section with output showing the duplicated API-key figure. * fix(usage): render a null count as unreported instead of throwing `unit_count: null` reached formatNumber on the non-duration path and threw a TypeError, so one unexpected null in the response took down `deepl usage` entirely rather than leaving one field blank. Also records in the changelog that write/correct entries for the five recased target codes are unreachable. They are left to their TTL rather than dropped: the key is a hash, so those rows cannot be distinguished from the ones for de/en/es/fr, which still serve, and purging the namespace would discard far more than it reclaimed. * fix(translate): validate languages in one place for every input mode Language validation was spread over eight call sites in five files and only three paired the target-code check with the extended-tier check, so file, directory and --dry-run runs accepted constrained options that text runs rejected locally: --to af --formality more exited 6 for text and went to the network for a file, and a dry run reported --to 'not!!a!!lang' as runnable. validateTranslationLanguages() is now the single gate. Each mode passes only the flags it honours, so nothing is refused over a flag its mode discards -- a document run still accepts --model-type, which it strips after warning, and a directory run still accepts --glossary, which it ignores. Also folds in the two rough edges around the same code path: --from is validated, with its own wording, and the deferral note is said once per code per run rather than once per call site. The extended-tier glossary arm reads hasGlossarySelection(), so an empty repeatable --glossary list no longer counts as selecting one. Closes cli-01on.2, cli-01on.1, cli-01on.7 * fix(voice): validate options before resolving --glossary, and check coverage Resolution costs a glossary-list round trip, and it was spent before the target language, source language and content type were checked -- so 'voice a.ogg --to bogus --glossary g' paid for a request and then failed locally. The local checks move into an exported validateVoiceOptions() that the registrar calls first and buildOptions reuses. Resolution now also passes the requested pair, so a glossary that does not cover it fails locally the way it already did for translate, watch and sync. The pair is the canonical spelling voice sends; without --from there is no pair and the API keeps the judgement. Closes cli-01on.3 * fix(sync): check override glossaries at startup, and validate during --dry-run locale_overrides.<locale>.glossary was resolved with no expected pair, so it skipped the coverage preflight the top-level glossary and the TM override already had. It was also resolved inside the bucket loop, once per file, which means a reference that does not cover its locale failed only after earlier files had been translated and written. Both kinds of override now resolve once in the orchestrator, against their own locale, and the resolved maps are threaded into processBucket -- so that function no longer needs the glossary service, the translation service or the TM cache. sync --dry-run also drops its resolution guards. It already requires an API key to get that far, and a glossary or TM the config names but the account cannot use for the pair is what a preview exists to report. Zero /v2/translate calls is unchanged and still asserted. Closes cli-01on.4 * test(languages): pin the supportsFormality JSON contract The field moved from v2's supports_formality boolean to the v3 features matrix, and is now omitted for a target whose features the response does not describe rather than reported as false. /v3/languages carries a matrix for all 125 languages it lists, so the serialized shape is the same one 1.2.0 produced -- present and boolean for every target, absent for every source. Nothing was asserting that, so nothing would have caught it changing. Also states the omission case in the CHANGELOG next to the "existing JSON consumers are unaffected" claim, which spoke only for the features matrix. Closes cli-01on.5 * docs: correct example output, the language endpoint, and the glossary rule Display output went lowercase in d15bc89 but several examples still showed `[ES]` and `| ES |` table cells, so copied output did not match reality. Two file-translation examples were wrong beyond casing: a single-target run prints `Translated x -> y`, not a `to 1 language(s):` list. Verified each against a real run. TROUBLESHOOTING's reachability check called /v2/languages, which this release migrates off; it now uses /v3/languages?resource=translate_text and names both hosts, since the free host answers a Pro key with 403 -- a reachable server, which is what the check is asking about. --glossary was documented as requiring --from in four places, which stopped being true when the requirement started accepting defaults.sourceLang, and the translate flag reference omitted it entirely. All five now state the implemented rule. Adds two mechanical guards to the docs suite, which previously checked only that documented commands and flags exist: no documented line may show an uppercase language code (matched against the registry, so markdown badges are left alone), and no doc may reference a retired v2 language endpoint. Closes cli-01on.9 * fix(watch): settle the glossary source language in the handler, not the registrar register-watch filled options.from from defaults.sourceLang before calling watch(), while the handler kept a flag-only guard -- so a library consumer calling WatchCommand.watch() directly with a glossary and a configured source language got a rejection the CLI does not make. The handler now calls the shared applyGlossarySourceLang, as every other command does, and WatchCommand takes the config service to read the default from. The parameter is optional, so a caller with no configuration still gets the requirement, just without a default to satisfy it from. Closes cli-01on.6 * fix(languages,voice): sanitize API-provided strings before printing them languages printed the name, feature keys and feature statuses from /v3/languages raw, and voice did the same with transcript text and language labels, while the glossary and style-rule listings hardened in this release already wrapped API strings in sanitizeForTerminal. Logger's own sanitizer only redacts secrets; it does not strip ANSI or bidi. It matters most for voice, whose live display clears a fixed number of lines: a newline inside a transcript deranges the whole rendering rather than one line of it. --format json keeps the text verbatim, since JSON.stringify escapes control characters and that path feeds machines. Test strings spell the hostile characters as JS unicode escapes rather than embedding literal control bytes, which do not survive editing reliably. Closes cli-01on.8 * fix(translate): report a directory run's rejection class in the exit code A directory run where the API refused the request itself exited 1, the generic "nothing succeeded" code, even though the run aborted for a reason the caller can act on. The batch services already tracked that rejection to stop sending doomed batches; it is now surfaced on the result, and the handler reports its exit code -- 6 for a refused target_lang, 4 for an exhausted quota, 2 for a refused key. A run whose files failed individually still exits 1, or 12 with some successes. Text and file modes already exited 6 here, since the rejection reaches the top-level handler as a typed ValidationError; only the directory path substituted its own code. Also resets process.exitCode after each test in the directory handler suite. The handler writes to the jest process's own exit code, which left set made the suite exit non-zero with every test passing. * fix(glossary): relax the coverage check on the requested side only The preflight compared both sides on their base language, so a pt-br dictionary satisfied a request for pt-pt -- a different language pair. The relaxation exists because dictionaries name base languages while --to accepts regional variants, which needs it in one direction: a dictionary language now matches the requested one exactly, or matches the base it reduces to. de->en still covers --to en-us, and a dictionary naming pt-br still matches --to pt-br. Not reachable through the current API, whose glossary language set is entirely base codes, but GlossaryClient types targets as Language, which includes the variants. * fix(languages): stop inferring a tier, and a role, from a silent response Three related conflations of "unknown" with "no", all in how a /v3/languages entry is read: deriveLanguageEntry filed a language with no features matrix as extended, inferring absent glossary support from silence. The extended tier is what refuses formality and glossary before a request is sent, so that claims knowledge the response did not give -- the same fault already fixed for the --features table. Such a language is now tiered by source usability and the API keeps the judgement. An empty matrix is still evidence, and still extended. The bundled snapshot is unchanged, since every language the API lists today carries a matrix. The role flags had two conventions: the registry reads `usable_as_source !== false` while the translation and glossary clients filtered on truthiness, so an entry with the flag absent was dropped from `deepl languages` but recorded as core by the generator. Both clients now read it the registry's way. LanguageEntry's fields are readonly, because the accessors hand out the registry's own objects: mutating one changed the registry for the rest of the process, which the generated `as const` snapshot forbids but the widened element type allowed. * fix(scripts): report a thrown fetch from the language generator as an error The generator had no top-level catch, so a rejected fetch or an error body that does not parse surfaced as an unhandled rejection with a stack trace rather than the script's own "error:" line and exit 1. Every other failure in the script already reported itself that way. Covered by a test that preloads a throwing fetch, which fails without the catch. * docs(glossary): record the verified multipart glossary_ids semantics The multi-glossary work could not establish how the multipart POST /v2/document parses glossary_ids, because that endpoint answers any unresolvable glossary with `glossary_ids is not valid` whatever the encoding, so probing with fake UUIDs cannot separate a parse failure from a non-existent glossary. Two real glossaries settle it. A comma-joined glossary_ids applies the terms from every glossary in the list. The same two IDs sent as repeated multipart fields apply only the first, and the API reports no error for the ones it drops, so the comma-joining encodeGlossaryIdsForMultipart() performs is required rather than merely sufficient and its absence would fail silently. Whitespace after the commas turns out to be tolerated, contrary to what the comment claimed; the encoder emits none either way, so this is a comment correction only. * fix(glossary): stop documenting flag order as resolving a conflicting term --glossary was documented as last-one-wins: with a term defined in several glossaries, the last flag on the command line was said to decide the translation. Two real glossaries defining the same source term show that it does not. The winner did not track list order — the same glossary won under both orderings in five of six trials, and the first-listed one won in the sixth. Differing verb choices between those two runs confirm they were separate translations rather than cache hits, so conflict resolution belongs to the API and is context-dependent, exactly as glossary matching itself already is. Nothing about the request changes. The list is still sent in the caller's order and never sorted, which stays correct for the reason the cache always needed it: a reordered list is a different request, so it gets its own cache entry. Only the rationale was wrong, and four tests carried that rationale in their names. The --help text now says the API picks the winner regardless of flag order, so users have no reason to reorder flags expecting an override. * docs(test): correct the nock leak comment's attribution and audit rule The block added in dd7591c pinned the leak on six `replyWithError` sites in deepl-client.test.ts plus three integration files. Tracing the handles says otherwise: they arise in the translate() and error handling blocks, through ordinary `.reply(...)` interceptions. That file has 122 `.reply()` calls against 2 `replyWithError`, only one of which even sits in a leaking block, and the replyWithError inventory has since grown to 14 call sites across 6 files. That stale baseline made the audit instruction unusable: it asked for "any NEW leak source beyond the six known replyWithError sites", which names the wrong construct and drifts every time a test moves. The rule is now type-based — every reported handle should be HTTPINCOMINGMESSAGE from @mswjs/interceptors, and anything else is a real leak. The mechanism and both mitigations were re-verified and kept: forceExit does not suppress the warning, and --runInBand avoids it at ~5x wall clock. Whether a newer @mswjs/interceptors helps is now recorded as untested rather than assumed, since 0.42 is ESM-only and fails the CJS transform. * docs: correct inaccuracies found in the pre-2.0 audit `docs/API.md` and `README.md` said the last `--glossary` wins a conflicting term, contradicting the same files, the flag's own help text, and the verified behaviour: which mapping wins is the API's choice and does not follow flag order. `docs/API.md`'s command-group table omitted `correct` while claiming to match `deepl --help`, and the README's table of contents omitted its Spelling and Grammar Correction section. A new check in the documented-surface suite fails when the table and the CLI's top-level commands disagree in either direction. `docs/API.md`'s environment-variable reference omitted `NO_PROXY`, which the HTTP client honours and the README already documented. `docs/TROUBLESHOOTING.md` attributed "Translation cache backend failed to load" to running Node.js < 24, which the startup version check rejects earlier with its own message, so the stated cause was unreachable. That entry now describes the reachable case — a v24+ runtime with no usable `node:sqlite` — and the version error is documented under exit code 6, where it appeared nowhere. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(watch): honour the documented 500ms debounce default and watch.debounceMs `deepl watch` debounced at 300 ms while `--help`, the docs and the config schema all said 500. The command forwarded a debounce only when `--debounce` was passed, so an omitted flag fell through to `WatchService`'s own 300 ms fallback — a third copy of a value the schema and the help text both put at 500. `watch.debounceMs` was also dead: `deepl config set` accepted it and the documented schema listed it, but nothing read it for `watch`. Resolution is now the flag, then `watch.debounceMs`, then the documented default, which is a single exported constant shared with the config schema so the two cannot drift again. `deepl sync --watch` already resolved to 500 and is unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor(exit-codes): consolidate on exitCodeForError `getExitCodeFromError` was a same-body alias documented as kept "for backwards compatibility with callers that import the old name", but `exit-codes.ts` is not exported from the package entry, so no such caller could exist — the only `src/` use was the CLI's own top-level handler. Dropping it also removes a redundant guard at that call site: the alias narrowed its parameter to `Error`, so the handler tested `instanceof Error` and supplied `GeneralError` itself. `exitCodeForError` takes `unknown` and already returns `GeneralError` for a non-`Error`, so the ternary is now a single call with identical behaviour. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * style: apply prettier to src and tests `npm run format:check` failed on 409 of the repo's TypeScript files, so the gate could not be used to catch anything. This applies the existing `.prettierrc` with no configuration changes. `src/data/language-entries.ts` stays untouched, as `.prettierignore` requires: `check:languages` compares its raw text, so reformatting it would report permanent drift. Reformatting relocated four `eslint-disable-next-line` directives away from the lines they suppressed, because rewrapping to the 80-column width split those lines in two. That turned one into a `require-yield` error and left `prefer-nullish-coalescing` reported 15 times against deliberate `||` uses. Each directive is restored over the code it covers, using the block disable/enable form where a statement now spans several lines and a `-next-line` directive cannot reach it. `paths.ts` and `run-cli.ts` keep `||` on purpose — an empty string is a value `??` would pass through, but which the XDG spec and the exit-status handling both need to treat as unset. Lint, type-check, `format:check` and all 284 suites pass; lint is back to the zero-warning baseline it had before the reformat. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * chore: prune dead .gitignore entries and un-ignore db fixtures Remove patterns nothing in the repo produces: build/ and lib/ (output only goes to dist/), .nyc_output/ (coverage is Jest), .npm, the yarn/pnpm/npm debug logs and logs/ (redundant with *.log), *.pid/*.seed/*.pid.lock, cert and credential files, Windows-only OS files, the .cache/.temp/.tmp family, and .deepl-cli/ (the legacy config dir only resolves under $HOME). Add !tests/fixtures/*.db so committed fixture databases are not silently ignored by *.db; the transient sqlite sidecars stay ignored. Local per-clone tooling (.beads/, issues.jsonl, qa/, .playwright-mcp/) moves to .git/info/exclude. The set of ignored files is unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * chore: prune redundant .prettierignore entries node_modules is ignored by prettier natively, and *.log and .DS_Store are skipped during directory traversal because no parser can be inferred, so none of the three changed what prettier touched. dist and coverage stay: prettier does descend into them when pointed at the repo root. Verified src/data/language-entries.ts is still ignored, and that prettier would rewrite it without that entry. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * ci: gate formatting and fail lint on warnings `format:check` and `lint` both existed but neither was enforced as written: nothing ran `format:check`, and `lint` exited 0 on warnings. The prettier adoption in 01ec9a3 showed why both matter — rewrapping moved four `eslint-disable-next-line` directives off the lines they suppressed, and the 15 resulting warnings would have gone green. - `lint` now runs with `--max-warnings 0`, matching the zero-warning baseline the repo already held. - CI runs `format:check` before `lint`, so formatting drift is reported as formatting rather than as the lint failures it goes on to cause. - `format:staged` formats staged `src/` and `tests/` TypeScript and re-stages it, for use from a pre-commit hook, so formatting is applied rather than remembered. `.git/hooks` is not version-controlled, so CONTRIBUTING.md documents enabling it per clone. Prettier runs twice over the staged set. It preserves whether an object literal was written multi-line, which interacts with member-chain breaking: an over-long single-line object ending a chain formats to "chain broken, object expanded", then to "chain collapsed, object hugged", which is the fixed point. A single pass would leave content that `format:check` then rejects. A partially staged file is reported and skipped, since reformatting and re-adding it would stage hunks the author left out. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * chore: guard test fixtures in .prettierignore A root-level prettier run reached test fixtures, which are byte-exact inputs. en-reserved-words.yaml quotes "yes"/"no" so YAML 1.1 cannot read them as booleans, and prettier rewrites those to single quotes; the sync context fixtures are parsed for surrounding source, so rewriting them changes what the extractor sees. No change to the format scripts' scope, which stays src/ and tests/; format:check still passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(test): restore coverage source maps in ts-jest transform Coverage was measured against the emitted JavaScript without source maps, so istanbul attributed statements and functions to arbitrary positions -- import lines and JSDoc blocks were reported as uncovered, and downlevel helpers appeared as never-executed anonymous functions. Reported totals sat 4-8 points below the real ones and every configured threshold failed while all tests passed. tsconfig.json sets sourceMap:false so maps stay out of the published tarball, and ts-jest merges the transform's inline options over that file rather than replacing it, so the setting has to be re-enabled here. Measured against the same 6536 tests, no test changes: statements 90.47% -> 94.84% (threshold 93) branches 82.36% -> 88.64% (threshold 86) lines 92.15% -> 95.30% (threshold 93) functions 86.50% -> 94.71% (threshold 94) npm run test:coverage now exits 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * test: cover parsePositiveIntOption…
1 parent 3559742 commit e08225a

586 files changed

Lines changed: 73262 additions & 18281 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/ISSUE_TEMPLATE/bug_report.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ A clear, concise description of the bug.
1515
- **DeepL CLI version**: (run `deepl --version`)
1616
- **Node.js version**: (run `node --version`)
1717
- **Operating system**: (e.g., macOS 14.5, Ubuntu 22.04, Windows 11)
18-
- **Install method**: (source, npm link, other)
18+
- **Install method**: (npm global, npx, Homebrew, source checkout, npm link, other)
1919

2020
## Reproduction Steps
2121

‎.github/workflows/ci.yml‎

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ jobs:
1616

1717
strategy:
1818
matrix:
19-
node-version: [24]
19+
# 24.15.0 is the `engines.node` floor — the release where node:sqlite
20+
# stopped emitting an ExperimentalWarning. Bare `24` resolves to the
21+
# latest 24.x, so without the pin the declared floor is never exercised.
22+
node-version: ['24.15.0', '24']
2023

2124
steps:
2225
- uses: actions/checkout@v7
@@ -27,8 +30,61 @@ jobs:
2730
cache: npm
2831

2932
- run: npm ci
33+
# Ahead of lint: reformatting can move an eslint-disable directive off the
34+
# line it suppresses, so drift should be reported as formatting rather
35+
# than as the lint failures it goes on to cause.
36+
- run: npm run format:check
3037
- run: npm run lint
3138
- run: npm run type-check
3239
- run: npm run check-deps
3340
- run: npm run build
34-
- run: npm test
41+
- run: npm run test:coverage
42+
43+
# The suites run from the working tree, so nothing above proves the published
44+
# artifact works. `bin` and the package entry have separate module graphs — a
45+
# broken entry point ships while the CLI still passes every test.
46+
package:
47+
name: Packaged artifact
48+
runs-on: ubuntu-latest
49+
50+
steps:
51+
- uses: actions/checkout@v7
52+
53+
- uses: actions/setup-node@v7
54+
with:
55+
node-version: '24'
56+
cache: npm
57+
58+
- run: npm ci
59+
- run: npm run build
60+
61+
- name: Pack
62+
id: pack
63+
run: echo "tarball=$(npm pack --silent)" >> "$GITHUB_OUTPUT"
64+
65+
- name: Install globally and run the CLI
66+
env:
67+
TARBALL: ${{ steps.pack.outputs.tarball }}
68+
run: |
69+
npm install -g "./$TARBALL"
70+
deepl --version
71+
72+
# From a throwaway consumer package, because Node does not resolve
73+
# bare specifiers out of the global prefix.
74+
- name: Import the package entry as a dependency
75+
env:
76+
TARBALL: ${{ steps.pack.outputs.tarball }}
77+
run: |
78+
tarball_path="$PWD/$TARBALL"
79+
consumer="$(mktemp -d)"
80+
cd "$consumer"
81+
npm init -y >/dev/null
82+
npm install "$tarball_path"
83+
node --input-type=module -e "
84+
const m = await import('@deepl/cli');
85+
const keys = Object.keys(m);
86+
if (keys.length === 0) {
87+
throw new Error('package entry resolved but exported nothing');
88+
}
89+
console.log('package entry exports:', keys.join(', '));
90+
"

‎.github/workflows/release.yml‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
name: Release
22

3-
# This workflow only records the GitHub Release for a tag. Publishing to npm
4-
# happens from GitLab, so no `npm publish` step or NPM_TOKEN secret belongs in
5-
# this repository.
63
on:
74
push:
85
tags:
@@ -18,8 +15,6 @@ jobs:
1815
steps:
1916
- uses: actions/checkout@v7
2017

21-
# A tag can be pushed at any commit, so a mislabelled tag would otherwise
22-
# mint a Release whose title disagrees with the version it contains.
2318
- name: Verify tag matches package version
2419
run: |
2520
tag_version="${GITHUB_REF_NAME#v}"
@@ -29,8 +24,6 @@ jobs:
2924
exit 1
3025
fi
3126
32-
# Release notes come from the tag's CHANGELOG section; releases are
33-
# immutable once published, but notes stay editable afterwards.
3427
- name: Extract changelog section
3528
id: notes
3629
run: |
@@ -44,7 +37,6 @@ jobs:
4437
echo "has_notes=true" >> "$GITHUB_OUTPUT"
4538
fi
4639
47-
# Skipped when the release exists, so re-runs are idempotent.
4840
- name: Create GitHub Release
4941
env:
5042
GH_TOKEN: ${{ github.token }}

‎.gitignore‎

Lines changed: 4 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
# Dependencies
22
node_modules/
3-
.npm
43

54
# Build outputs
65
dist/
7-
build/
8-
lib/
96
*.tsbuildinfo
107

118
# Testing
129
coverage/
13-
.nyc_output/
1410

1511
# Environment variables
1612
.env
@@ -19,11 +15,6 @@ coverage/
1915
# Secrets and credentials
2016
*.pem
2117
*.key
22-
*.p12
23-
*.pfx
24-
*.cert
25-
*.crt
26-
credentials.json
2718

2819
# IDEs and editors
2920
.vscode/
@@ -34,47 +25,20 @@ credentials.json
3425

3526
# OS files
3627
.DS_Store
37-
Thumbs.db
38-
Desktop.ini
39-
40-
# Debug logs
41-
npm-debug.log*
42-
yarn-debug.log*
43-
yarn-error.log*
44-
pnpm-debug.log*
45-
46-
# Runtime data
47-
*.pid
48-
*.seed
49-
*.pid.lock
5028

5129
# Logs
52-
logs/
5330
*.log
5431

55-
# Cache and temporary files
56-
.cache/
57-
.temp/
58-
.tmp/
59-
*.tmp
32+
# Caches
6033
.eslintcache
6134

62-
# DeepL CLI specific
63-
.deepl-cli/
35+
# SQLite databases; the cache lives outside the repo, but a stray
36+
# DEEPL_CONFIG_DIR could land one here. Fixture databases are committed.
6437
*.db
38+
!tests/fixtures/*.db
6539
*.db-journal
6640
*.db-wal
6741
*.db-shm
6842

6943
# Claude Code local tooling (settings, hooks, agent worktrees, session state)
7044
.claude/
71-
72-
# Playwright MCP local state
73-
.playwright-mcp/
74-
75-
# Beads issue tracking (local)
76-
.beads/
77-
issues.jsonl
78-
79-
# QA harness
80-
qa/

‎.prettierignore‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
1-
node_modules
1+
# The format scripts only cover src/ and tests/, but prettier reaches
2+
# everything below when pointed at the repo root.
3+
4+
# Build and coverage output
25
dist
36
coverage
4-
*.log
5-
.DS_Store
7+
8+
# Generated from GET /v3/languages; check:languages compares the raw text, so
9+
# reformatting it would report permanent drift.
10+
src/data/language-entries.ts
11+
12+
# Fixtures are byte-exact test inputs. Some encode the very thing under test:
13+
# en-reserved-words.yaml quotes "yes"/"no" so YAML 1.1 cannot read them as
14+
# booleans, and prettier rewrites those quotes.
15+
tests/fixtures/

0 commit comments

Comments
 (0)