diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db605f..1f0e93e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,9 +73,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +- **BREAKING — auth**: `deepl auth set-key ` no longer accepts the key as an argument and exits **6** naming the supported paths. The positional form warned as deprecated on every use since 1.0.0: a command line is readable by other users through process listings — `/proc//cmdline` is world-readable on Linux unless `/proc` is mounted `hidepid`, and `ps` discloses other users' argv on macOS as well — and it is recorded in shell history, where it outlives the process indefinitely. Pipe the key instead (`echo "$KEY" | deepl auth set-key --from-stdin`), or redirect a file into it (`deepl auth set-key --from-stdin < keyfile`), which also keeps it out of history, or run `deepl init`, which prompts with masked input. Redirected stdin without the flag is unchanged, so `deepl auth set-key < keyfile` still works, and `--from-stdin` is now the explicit spelling of the only source rather than a switch between two. The argument is still declared to the parser so the value can be refused by name: commander's own excess-argument error quotes the offending token, which would have written the key to stderr — the stream CI captures and retains — turning a transient `ps` exposure into a durable one. +- **BREAKING — sync**: `tms.api_key` and `tms.token` are gone from the `.deepl-sync.yaml` schema, leaving `TMS_API_KEY` and `TMS_TOKEN` as the only credential source. Both were accepted with a stderr warning through 1.x and now fail config load with a `ConfigError` (exit 7) that names the environment variable to use instead — `tms.api_key is no longer read from .deepl-sync.yaml` — and never quotes the value. This file is committed, which is its purpose, so a credential written into it was a secret in version control: present in every clone and fork, and surviving its own deletion from the file, so **rotate any credential that has ever been pushed**. Nothing working depended on the file being read, since the environment variable already won wherever both were set. Two consequences reach past the schema. The TMS destination-trust gate loses its bypass: it applied only to environment-supplied credentials, on the reasoning that a credential inlined in the same file that chose the destination leaked nothing of the operator's, and now that every credential comes from the environment, every destination is checked. And `SyncTmsConfig` drops both fields, along with the `'config'` member of the internal credential-provenance type. - **BREAKING — sync**: `tms.auto_push`, `tms.auto_pull` and `tms.require_review` are gone from the config schema. All three were on the `tms:` allowlist and in `docs/SYNC.md`, and **no code read any of them**, so a review gate configured through `require_review` was doing nothing. Each now fails config load with a `ConfigError` (exit 7) naming it — `tms.require_review was never implemented and has been removed` — rather than as a generic unknown field, which would read as a typo. `require_review` is not implementable from this side, since the documented export contract is a flat `{ key: value }` map with no per-entry review flag; use `deepl sync pull --dry-run` to preview a pull instead, and run `deepl sync push` / `deepl sync pull` explicitly in place of the auto flags. - **BREAKING — cli**: The `--enable-beta-languages` flag on `translate` is gone. The API deprecated the underlying parameter as having no effect — beta languages are part of the regular language set — so the flag had become a silent no-op. Scripts passing it exit 6 with an unknown-option error; remove the flag. -- **BREAKING — sync**: `deepl sync init --source-lang` and `--target-langs`, the deprecated aliases introduced in 1.x, are removed and fail with `error: unknown option` (exit 6). Use `--source-locale` and `--target-locales`. `deepl translate --target-lang` is unaffected — it is the API's wire name, not a deprecated alias. +- **BREAKING — sync**: `deepl sync init --source-lang` and `--target-langs`, the deprecated aliases introduced in 1.x, are removed and fail with `error: unknown option` (exit 6). Use `--source-locale` and `--target-locales`. `deepl translate` is unaffected — it selects its target with `-t` / `--to`, which was never a locale alias. - **usage**: The dedicated "Speech-to-Text Usage" section (text output), the "Speech-to-text" row (table output) and the `speechToTextMilliseconds*` fields they read are gone, following the API's deprecation of `speech_to_text_milliseconds_count`/`_limit` ("Always returns 0"). Voice usage remains visible in the Product Breakdown, which reads live per-product minutes; the Admin API's per-key `speech_to_text_milliseconds` usage limit is a different, still-current field and is unaffected. - **deps**: `better-sqlite3` and `@types/better-sqlite3`. The production dependency tree no longer contains any native addon, removing the whole class of ABI-mismatch failures (`ERR_DLOPEN_FAILED` / `NODE_MODULE_VERSION` after a Node major upgrade), a 1.9 MB platform-specific binary, and the C++ compilation-toolchain requirement for installs from source. The cacheless-degradation safety net remains: a runtime whose `node:sqlite` is unusable warns once and runs uncached rather than crashing, and never touches the cache database. - **deps**: `inquirer`, which no source file imported. @@ -84,6 +86,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **config**: An empty `~/.deepl-cli/` directory no longer captures path resolution. The legacy layout was selected on the bare existence of that directory, so one left behind after `rm ~/.deepl-cli/config.json` or `rm ~/.deepl-cli/cache.db` — both of which `docs/TROUBLESHOOTING.md` tells you to run — silently won over an explicitly set `XDG_CONFIG_HOME` **and** `XDG_CACHE_HOME`, resolving config and cache into a directory holding neither and reporting nothing, so a user who set an XDG variable and found it ignored had no signal pointing at the leftover. The legacy layout now applies only when `config.json` or `cache.db` is actually present, so every real 1.x installation resolves exactly as before while an empty directory falls through to XDG. The precedence order is unchanged: `DEEPL_CONFIG_DIR` still wins outright, and a populated `~/.deepl-cli/` still beats XDG. + - **sync**: Two concurrent `deepl sync` runs in one directory can no longer both believe they hold the process lock. Reclaiming a pidfile already proven stale renames it aside and then confirms it is the same file before deleting it, but that confirmation compared inode and device only — and an inode number is reused once its file is unlinked, which is exactly what a sync winning the race does when it replaces the pidfile. On ext4 the freed inode comes straight back, so a winner's live pidfile compared equal to the stale one it replaced and was deleted, leaving both runs writing the same target files and the same lockfile. Identity now also requires the recorded `pid` and `startedAt` to match, which a different holder cannot satisfy. The behaviour was filesystem-dependent: APFS never reuses inodes, so this reproduced on Linux only. - **cli**: `deepl completion fish` no longer emits a broken line for a command or option whose description contains a backslash. Descriptions were escaped for the surrounding fish single quotes by replacing `'` only, so a trailing backslash escaped the closing quote and ran the rest of the generated line into the description. Backslashes are now escaped first, then quotes. The bash and zsh generators use the POSIX `'\''` form and were unaffected. - **sync**: `translation.locale_overrides..model_type` is now applied. The key was on the config allowlist and validated per locale against `translation_memory`, but the translator only ever read the top-level `translation.model_type`, so the per-locale value was silently dropped — and the validator's own error message named that inert scope as the remedy. A locale that configures translation memory per locale therefore got requests the TM could not be applied to, silently, and was billed for them. The override now resolves exactly like its siblings (`formality`, `translation_memory_threshold`, `custom_instructions`, `style_id`): the per-locale value wins, otherwise the top-level one applies. `SyncLocaleOverrides` also declares the field, and — as with those siblings — a per-locale override now takes precedence over `--model-type`. diff --git a/README.md b/README.md index 37f75a2..949cb13 100644 --- a/README.md +++ b/README.md @@ -127,9 +127,8 @@ Or set your API key directly: # Piping from stdin keeps the key out of process listings and shell history echo "YOUR_API_KEY" | deepl auth set-key --from-stdin -# Passing it as an argument also works, but is deprecated and warns: -# other users can read it via `ps` -deepl auth set-key YOUR_API_KEY +# Or from a file, keeping the key out of shell history +deepl auth set-key --from-stdin < ~/.deepl-api-key ``` Or use an environment variable: @@ -1007,10 +1006,6 @@ echo "YOUR_API_KEY" | deepl auth set-key --from-stdin echo "YOUR_API_KEY" | deepl auth set-key --from-stdin --no-verify # ✓ API key saved without validation -# Passing the key as an argument still works, but is deprecated and warns — -# other users can read it via `ps` -deepl auth set-key YOUR_API_KEY - # Show the stored key, masked (does not contact the API) deepl auth show # API Key: abc1...2def diff --git a/docs/API.md b/docs/API.md index 8d0ec78..3a693a2 100644 --- a/docs/API.md +++ b/docs/API.md @@ -167,7 +167,7 @@ deepl --config /path/to/test-config.json usage - **Environment separation**: Separate configs for dev/staging/production - **Testing**: Use test configurations without affecting default settings -**Precedence:** `--config` replaces the config _file_ only, overriding `DEEPL_CONFIG_DIR` for configuration. The cache location is unaffected — it still follows `DEEPL_CONFIG_DIR` > legacy `~/.deepl-cli/` > XDG resolution. +**Precedence:** `--config` replaces the config _file_ only, overriding `DEEPL_CONFIG_DIR` for configuration. The cache location is unaffected — it still follows `DEEPL_CONFIG_DIR` > legacy `~/.deepl-cli/` (when it holds `config.json` or `cache.db`) > XDG resolution. **Command Suggestions:** @@ -1374,7 +1374,7 @@ Interactive setup wizard that creates `.deepl-sync.yaml` by scanning the project - `--format FORMAT` - Output format: `text` (default), `json`. Under `json`, success emits the envelope described below and failure emits the shared error envelope, both on stdout - `--sync-config PATH` - Path to `.deepl-sync.yaml` -`--source-lang` and `--target-langs` were accepted as deprecated aliases during `1.x` and were removed in `2.0.0`; use `--source-locale` / `--target-locales`. `deepl translate --target-lang` is unchanged — it operates on strings and stays aligned with the DeepL API's wire name. +`--source-lang` and `--target-langs` were accepted as deprecated aliases during `1.x` and were removed in `2.0.0`; use `--source-locale` / `--target-locales`. `deepl translate` is unaffected — it selects its target with `-t` / `--to` and operates on strings rather than locale files. **Examples:** @@ -2798,17 +2798,14 @@ deepl auth #### Subcommands -##### `set-key [api-key]` +##### `set-key` -Set your DeepL API key and validate it with the DeepL API. - -**Arguments:** - -- `api-key` (optional) - Your DeepL API authentication key. If omitted, reads from stdin. +Set your DeepL API key and validate it with the DeepL API. The key is read from +stdin; there is no argument form. **Options:** -- `--from-stdin` - Read API key from stdin +- `--from-stdin` - Read API key from stdin. Stdin is the only source, so this flag is explicit rather than required. - `--no-verify` - Store the key without validating it against the API. Use on offline or proxied networks, where validation cannot reach the API and the key would otherwise be discarded. Exports of `DEEPL_API_KEY` also bypass validation entirely. **Examples:** @@ -2816,22 +2813,19 @@ Set your DeepL API key and validate it with the DeepL API. ```bash # Pipe key from stdin (recommended - avoids exposing key in process listings) echo "YOUR-API-KEY" | deepl auth set-key --from-stdin +# ✓ API key saved and validated successfully -# Read from file +# Read from file, keeping the key out of shell history deepl auth set-key --from-stdin < ~/.deepl-api-key -# Provide key as argument -deepl auth set-key YOUR-API-KEY-HERE -# ✓ API key saved and validated successfully - # Store without a network round-trip (offline, or behind an unconfigured proxy) echo "YOUR-API-KEY" | deepl auth set-key --from-stdin --no-verify # ✓ API key saved without validation ``` -**Security Note:** Prefer `--from-stdin` over passing the key as a command argument. Command arguments are visible to other users via process listings (`ps aux`). +**Security Note:** The key is never taken from the command line. Command arguments are visible to other users via process listings (`ps aux`) and are recorded in shell history, so `set-key` reads stdin only. Run `deepl init` to be prompted for the key with masked input instead. -> **Deprecation:** Passing the API key as a positional argument is deprecated and will emit a warning. Use `--from-stdin` instead for secure key input. +> **Removed in 2.0.0:** Passing the API key as a positional argument (`deepl auth set-key YOUR_KEY`) warned as deprecated in 1.x and now exits 6. Use `--from-stdin`, or `deepl init`. ##### `show` @@ -3276,11 +3270,14 @@ The CLI resolves configuration and cache paths using the following priority orde | Priority | Condition | Config path | Cache path | | -------- | ---------------------- | ---------------------------------------- | ------------------------------------ | | 1 | `DEEPL_CONFIG_DIR` set | `$DEEPL_CONFIG_DIR/config.json` | `$DEEPL_CONFIG_DIR/cache.db` | -| 2 | `~/.deepl-cli/` exists | `~/.deepl-cli/config.json` | `~/.deepl-cli/cache.db` | +| 2 | `~/.deepl-cli/config.json` or `~/.deepl-cli/cache.db` exists | `~/.deepl-cli/config.json` | `~/.deepl-cli/cache.db` | | 3 | XDG env vars set | `$XDG_CONFIG_HOME/deepl-cli/config.json` | `$XDG_CACHE_HOME/deepl-cli/cache.db` | | 4 | Default | `~/.config/deepl-cli/config.json` | `~/.cache/deepl-cli/cache.db` | -Existing `~/.deepl-cli/` installations continue to work with no changes needed. +Existing `~/.deepl-cli/` installations continue to work with no changes needed. An +empty `~/.deepl-cli/` directory is ignored — the legacy layout applies only when it +holds `config.json` or `cache.db`, so a directory left behind after deleting those +files does not shadow an explicitly set `XDG_CONFIG_HOME` or `XDG_CACHE_HOME`. ### Configuration Schema @@ -3366,7 +3363,7 @@ export DEEPL_CONFIG_DIR="/custom/path" ### `XDG_CONFIG_HOME` -Override XDG config base directory (default: `~/.config`). Config is stored at `$XDG_CONFIG_HOME/deepl-cli/config.json`. Only used when `DEEPL_CONFIG_DIR` is unset and legacy `~/.deepl-cli/` does not exist. +Override XDG config base directory (default: `~/.config`). Config is stored at `$XDG_CONFIG_HOME/deepl-cli/config.json`. Only used when `DEEPL_CONFIG_DIR` is unset and legacy `~/.deepl-cli/` holds neither `config.json` nor `cache.db`. ```bash export XDG_CONFIG_HOME="$HOME/.config" @@ -3374,7 +3371,7 @@ export XDG_CONFIG_HOME="$HOME/.config" ### `XDG_CACHE_HOME` -Override XDG cache base directory (default: `~/.cache`). Cache is stored at `$XDG_CACHE_HOME/deepl-cli/cache.db`. Only used when `DEEPL_CONFIG_DIR` is unset and legacy `~/.deepl-cli/` does not exist. +Override XDG cache base directory (default: `~/.cache`). Cache is stored at `$XDG_CACHE_HOME/deepl-cli/cache.db`. Only used when `DEEPL_CONFIG_DIR` is unset and legacy `~/.deepl-cli/` holds neither `config.json` nor `cache.db`. ```bash export XDG_CACHE_HOME="$HOME/.cache" @@ -3496,7 +3493,7 @@ Authentication failed or no API key is available. Emitted by: - Every command that touches the API (`translate`, `write`, `voice`, `glossary`, `usage`, `sync`, `tm list`, `admin`, etc.) when `DEEPL_API_KEY` is unset and no key is in the config file - HTTP 401/403 responses from the DeepL API -Remediation: run `deepl init` or `deepl auth set-key `, or export `DEEPL_API_KEY`. +Remediation: run `deepl init` or `deepl auth set-key --from-stdin < keyfile`, or export `DEEPL_API_KEY`. #### 3 — RateLimitError diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 1923850..de53b90 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -57,6 +57,9 @@ All of these fail immediately, so nothing here can pass silently. | `tms.auto_push` | Run `deepl sync push` after `deepl sync` | `ConfigError`, exit 7 | | `tms.auto_pull` | Run `deepl sync pull` before `deepl sync` | `ConfigError`, exit 7 | | `tms.require_review` | Preview with `deepl sync pull --dry-run` | `ConfigError`, exit 7 | +| `tms.api_key` | `TMS_API_KEY` environment variable | `ConfigError`, exit 7 | +| `tms.token` | `TMS_TOKEN` environment variable | `ConfigError`, exit 7 | +| `auth set-key ` (the key as an argument) | `auth set-key --from-stdin`, or `deepl init` | `ValidationError`, exit 6 | ```bash # 1.x @@ -86,6 +89,80 @@ tms: server: https://tms.example.com ``` +### The API key is no longer passed as an argument + +`deepl auth set-key ` warned as deprecated on every use since 1.0.0 and now +exits 6. A command line is readable by other users through process listings and is +recorded in shell history, where it outlives the process indefinitely. + +```bash +# 1.x — worked, with a warning on stderr +deepl auth set-key YOUR_API_KEY + +# 2.0.0 — pipe it in +echo "YOUR_API_KEY" | deepl auth set-key --from-stdin + +# 2.0.0 — or from a file, which also keeps it out of shell history +deepl auth set-key --from-stdin < ~/.deepl-api-key + +# 2.0.0 — or be prompted, with masked input +deepl init +``` + +In CI, replace the argument with a pipe from the secret, or skip the command and +export `DEEPL_API_KEY` instead: + +```yaml +# 1.x +- run: deepl auth set-key "$DEEPL_API_KEY" + +# 2.0.0 +- run: echo "$DEEPL_API_KEY" | deepl auth set-key --from-stdin +``` + +Redirected stdin without the flag is unchanged, so `deepl auth set-key < keyfile` +still works. The rejection message never quotes the value you passed, so a broken +CI job does not print your key into its log. + +### TMS credentials come only from the environment + +`tms.api_key` and `tms.token` were accepted in `.deepl-sync.yaml` with a warning +through 1.x, and are now refused at config load, which fails every `sync` +subcommand rather than one run. + +```yaml +# 1.x — accepted, with a warning on stderr +tms: + enabled: true + server: https://tms.example.com + project_id: my-project + api_key: sk-abc123 + +# 2.0.0 — remove it; set TMS_API_KEY in the environment instead +tms: + enabled: true + server: https://tms.example.com + project_id: my-project +``` + +```bash +export TMS_API_KEY=sk-abc123 # or TMS_TOKEN for bearer auth +``` + +**Rotate any credential you have committed.** `.deepl-sync.yaml` is a committed +file, so a credential that was ever pushed is in every clone and fork of the +repository and survives its own deletion from the file — deleting the line does not +remove it from git history. The environment variable already took precedence +wherever both were set, so nothing that worked before depends on the file. + +One behavioural consequence: the [TMS destination-trust prompt](SYNC.md#tms-destination-trust) +previously applied only to environment-supplied credentials, on the reasoning that a +credential inlined in the same file that chose the destination leaked nothing of +yours. Now that every credential comes from the environment, **every** destination is +checked, so a `tms.server` host you have not approved will prompt (or exit 7 where +there is no terminal) on a project that previously ran unprompted with an inlined +key. Approve it once with `deepl config set tms.allowedServers `. + Also gone: the `usage` command's "Speech-to-Text Usage" section and `speechToTextMilliseconds*` fields, following the API's deprecation of `speech_to_text_milliseconds_count`/`_limit`. @@ -342,17 +419,24 @@ assigned it *to* a `WriteLanguage` variable needs a check or a cast. 1. Move to Node 24.15.0 or later and reinstall from `@deepl/cli` (or `brew install deepl/tap/deepl`). 2. Remove `--enable-beta-languages`; rename `sync init --source-lang`/`--target-langs`. 3. Delete `tms.auto_push`, `tms.auto_pull` and `tms.require_review` from `.deepl-sync.yaml`. -4. Add `--yes` to any non-interactive `deepl sync --force`. -5. Re-check every exit-code branch in CI against the table above — especially any +4. Move `tms.api_key` / `tms.token` out of `.deepl-sync.yaml` into `TMS_API_KEY` / + `TMS_TOKEN`, and **rotate any credential that was ever committed** — deleting the + line does not remove it from git history. Approve your `tms.server` host with + `deepl config set tms.allowedServers `, which is now checked for every + credential. +5. Replace `auth set-key ` with `auth set-key --from-stdin` (or `deepl init`) + everywhere it appears — scripts, CI steps, Dockerfiles, runbooks. +6. Add `--yes` to any non-interactive `deepl sync --force`. +7. Re-check every exit-code branch in CI against the table above — especially any step treating `sync` or `watch` exit 0 as "complete". -6. Point JSON error parsing at stdout instead of stderr. -7. Fold case when comparing language codes from output; replace +8. Point JSON error parsing at stdout instead of stderr. +9. Fold case when comparing language codes from output; replace `hook.installed` with `hook.state === 'installed'`. -8. Expect lower `sync status` coverage for PO and XLIFF projects, and re-tune - `sync.max_characters` against the new `--dry-run` estimate. -9. Follow `watch --output` into its new nested layout, and clean up leftover - `.bak` files. -10. If you import the types, lowercase your `WriteLanguage` literals. +10. Expect lower `sync status` coverage for PO and XLIFF projects, and re-tune + `sync.max_characters` against the new `--dry-run` estimate. +11. Follow `watch --output` into its new nested layout, and clean up leftover + `.bak` files. +12. If you import the types, lowercase your `WriteLanguage` literals. The complete list of changes, including everything fixed that does not require action, is in diff --git a/docs/SYNC.md b/docs/SYNC.md index 6baaf5f..400efb8 100644 --- a/docs/SYNC.md +++ b/docs/SYNC.md @@ -24,7 +24,7 @@ ### Prerequisites -- DeepL API key (`deepl auth set-key YOUR_KEY` or `DEEPL_API_KEY` env var) +- DeepL API key (`deepl init` or `DEEPL_API_KEY` env var) - Project with i18n resource files (JSON, YAML, TOML, PO, Android XML, iOS Strings, ARB, XLIFF, Java Properties, Xcode String Catalog, or Laravel PHP arrays) ### First Sync in 30 Seconds @@ -502,16 +502,16 @@ Optional integration with a translation management system (TMS) for collaborativ | `enabled` | `boolean` | Yes | -- | Enable TMS integration | | `server` | `string` | Yes | -- | TMS server URL. HTTPS required, waived only for `http://localhost` and `http://127.0.0.1` -- write a local TMS as `http://localhost`, which reaches it whether it is bound to `127.0.0.1`, to `::1` or to every interface; `http://[::1]` and other loopback spellings are refused. When the credential comes from the environment, the hostname must also be approved -- see [TMS destination trust](#tms-destination-trust) | | `project_id` | `string` | Yes | -- | TMS project identifier | -| `api_key` | `string` | No | -- | API key for TMS authentication (prefer `TMS_API_KEY` env var) | -| `token` | `string` | No | -- | Bearer token for TMS authentication (prefer `TMS_TOKEN` env var) | | `timeout_ms` | `number` | No | `30000` | Per-request timeout in milliseconds for TMS HTTP calls (positive integer). Aborts the request via `AbortController` when exceeded. | | `push_concurrency` | `number` | No | `10` | Maximum number of in-flight `PUT /keys/{keyPath}` requests during `deepl sync push`. Positive integer. Applied per (file, locale) batch of entries; aborts remaining pushes on first failure. | +**Credentials are not configurable here.** `TMS_API_KEY` and `TMS_TOKEN` are the only source. This file is committed, so a credential written into it is a secret in version control -- present in every clone and fork, and surviving its own deletion in git history. `tms.api_key` and `tms.token` were accepted through `2.0.0`'s predecessors with a warning; they now fail config load with a `ConfigError` (exit 7) that names the environment variable to use instead. The message never quotes the value. If a credential was ever committed, rotate it: removing it from the file does not remove it from history. + **Removed fields.** `auto_push`, `auto_pull` and `require_review` were accepted by the schema through `1.x`, and no code ever read any of them -- a `require_review` a user configured expecting a human gate before pull got no gate and no warning. All three now fail config load with a `ConfigError` (exit 7) naming them, rather than being silently tolerated. Push and pull after or before a sync by running `deepl sync push` / `deepl sync pull` explicitly, which also keeps the credential and destination decision on a command you typed. A review gate cannot be enforced from the CLI side because the export contract below carries no per-entry review flag; use [`deepl sync pull --dry-run`](#deepl-sync-pull) to preview a pull and review the result before committing it. ##### TMS destination trust -`server` is chosen by this file, which lives in the checkout, while `TMS_API_KEY` / `TMS_TOKEN` come from the operator's environment. A checkout you do not control could therefore name the host that receives your credential and every translated string. Before an **environment-supplied** credential is attached to a request, the destination hostname must be approved in the operator's own configuration: +`server` is chosen by this file, which lives in the checkout, while `TMS_API_KEY` / `TMS_TOKEN` come from the operator's environment. A checkout you do not control could therefore name the host that receives your credential and every translated string. Before a credential is attached to a request, the destination hostname must be approved in the operator's own configuration: ```bash # Approve a destination up front (comma-separate several hosts) @@ -524,7 +524,7 @@ deepl config set tms.allowedServers tms.example.com Matching is against a parsed URL hostname: exact and case-insensitive, ignoring scheme, port and path. A listed `example.com` does **not** approve `tms.example.com`, and there are no wildcards. `localhost` and `127.0.0.1` are **not** exempt — a co-tenant process listening on loopback is still an exfiltration sink, even though `buildUrl` waives the HTTPS requirement for them. -The gate does not apply to a credential inlined as `api_key` / `token` in this file: it belongs to the same file that chose the destination, so nothing of the operator's leaks. Inlining a credential is still discouraged (it commits a secret), and the CLI warns about it separately. +The gate has no bypass: every credential now comes from the environment, so every destination is checked. `deepl sync push` and `deepl sync pull` print the resolved destination origin on success in both text and JSON output, so a redirected destination is visible in logs even for an already-approved host. @@ -548,7 +548,7 @@ The built-in `push`/`pull` client expects the TMS server to implement these endp | `GET` | `{server}/api/projects/{projectId}/keys/export?format=json&locale={locale}` | Pull approved translations. Response: `{ "key": "translated value", ... }` | | `GET` | `{server}/api/projects/{projectId}` | Project status (reserved; not currently consumed by the CLI) | -Authentication is sent as an `Authorization` header. When `api_key` is configured the header is `ApiKey {api_key}`; when `token` is configured it is `Bearer {token}`. Any TMS implementing this contract — DeepL's own, or a self-hosted bridge in front of Crowdin/Lokalise/Phrase/etc. — can be used. +Authentication is sent as an `Authorization` header. When `TMS_API_KEY` is set the header is `ApiKey {TMS_API_KEY}`; when `TMS_TOKEN` is set it is `Bearer {TMS_TOKEN}`. Any TMS implementing this contract — DeepL's own, or a self-hosted bridge in front of Crowdin/Lokalise/Phrase/etc. — can be used. #### `ignore` @@ -758,7 +758,7 @@ deepl sync init [OPTIONS] | `--path ` | Source file path or glob pattern | | `--sync-config ` | Path to `.deepl-sync.yaml` (default: auto-detect) | -`--source-lang` and `--target-langs` were accepted as deprecated aliases during `1.x` and were removed in `2.0.0`; use `--source-locale` and `--target-locales`. The `--locale` filter on `sync push` / `pull` / `status` / `export` is unchanged. `deepl translate --target-lang` is unchanged — it operates on strings and stays aligned with the DeepL API's wire name. +`--source-lang` and `--target-langs` were accepted as deprecated aliases during `1.x` and were removed in `2.0.0`; use `--source-locale` and `--target-locales`. The `--locale` filter on `sync push` / `pull` / `status` / `export` is unchanged. `deepl translate` is unaffected — it selects its target with `-t` / `--to` and operates on strings rather than locale files. **Examples:** diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 5338184..4c66a07 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -45,7 +45,7 @@ Common issues and solutions when using the DeepL CLI. 2. Set or update your key: ```bash - deepl auth set-key YOUR_API_KEY + echo "YOUR_API_KEY" | deepl auth set-key --from-stdin ``` 3. Alternatively, use the environment variable: @@ -97,9 +97,9 @@ Common issues and solutions when using the DeepL CLI. 2. Ensure you're copying the full key, including the `:fx` suffix for free-tier keys. -3. As a manual fallback, skip the wizard and set the key directly: +3. As a manual fallback, skip the wizard and pipe the key in: ```bash - deepl auth set-key YOUR_API_KEY + echo "YOUR_API_KEY" | deepl auth set-key --from-stdin ``` --- @@ -354,7 +354,7 @@ The config file location depends on your setup (see [Configuration Paths](../REA ```bash rm ~/.config/deepl-cli/config.json # or ~/.deepl-cli/config.json - deepl auth set-key YOUR_API_KEY + echo "YOUR_API_KEY" | deepl auth set-key --from-stdin ``` 4. Use a custom config directory: diff --git a/examples/01-basic-translation.sh b/examples/01-basic-translation.sh index 5eeddc4..9476718 100755 --- a/examples/01-basic-translation.sh +++ b/examples/01-basic-translation.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/02-file-translation.sh b/examples/02-file-translation.sh index 75180fa..dc49a8c 100755 --- a/examples/02-file-translation.sh +++ b/examples/02-file-translation.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/03-batch-processing.sh b/examples/03-batch-processing.sh index 7c07b23..2dd18ab 100755 --- a/examples/03-batch-processing.sh +++ b/examples/03-batch-processing.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/04-context-aware-translation.sh b/examples/04-context-aware-translation.sh index 292b258..46354dc 100755 --- a/examples/04-context-aware-translation.sh +++ b/examples/04-context-aware-translation.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/05-document-translation.sh b/examples/05-document-translation.sh index 6d56a5c..a84d787 100755 --- a/examples/05-document-translation.sh +++ b/examples/05-document-translation.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/06-document-format-conversion.sh b/examples/06-document-format-conversion.sh index 5ced25e..3f985a4 100755 --- a/examples/06-document-format-conversion.sh +++ b/examples/06-document-format-conversion.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/07-structured-file-translation.sh b/examples/07-structured-file-translation.sh index 812e1aa..1c26a2f 100755 --- a/examples/07-structured-file-translation.sh +++ b/examples/07-structured-file-translation.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/08-model-type-selection.sh b/examples/08-model-type-selection.sh index aa8431a..e3cd3d3 100755 --- a/examples/08-model-type-selection.sh +++ b/examples/08-model-type-selection.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/09-xml-tag-handling.sh b/examples/09-xml-tag-handling.sh index f4aba09..5449680 100755 --- a/examples/09-xml-tag-handling.sh +++ b/examples/09-xml-tag-handling.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/10-custom-instructions.sh b/examples/10-custom-instructions.sh index dd7bfab..5a52627 100755 --- a/examples/10-custom-instructions.sh +++ b/examples/10-custom-instructions.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/11-table-output.sh b/examples/11-table-output.sh index 0fcda2a..48aef38 100755 --- a/examples/11-table-output.sh +++ b/examples/11-table-output.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/12-cost-transparency.sh b/examples/12-cost-transparency.sh index 97cf5e2..3abf457 100755 --- a/examples/12-cost-transparency.sh +++ b/examples/12-cost-transparency.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi @@ -92,7 +92,7 @@ Run the following command: npm install -g @deepl/cli ``` -Then configure your API key: `deepl auth set-key YOUR_KEY` +Then configure your API key: `deepl init` EOF echo " Translating with code preservation..." diff --git a/examples/13-write.sh b/examples/13-write.sh index a1f6443..2dcf501 100755 --- a/examples/13-write.sh +++ b/examples/13-write.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/14-write-extended-languages.sh b/examples/14-write-extended-languages.sh index 41ec73d..d8eb77e 100755 --- a/examples/14-write-extended-languages.sh +++ b/examples/14-write-extended-languages.sh @@ -10,7 +10,7 @@ echo if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/15-correct.sh b/examples/15-correct.sh index 739c2ca..4eaaa94 100755 --- a/examples/15-correct.sh +++ b/examples/15-correct.sh @@ -10,7 +10,7 @@ echo if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/16-voice.sh b/examples/16-voice.sh index 29b8893..dfece9b 100755 --- a/examples/16-voice.sh +++ b/examples/16-voice.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/17-glossaries.sh b/examples/17-glossaries.sh index 2f6659e..f096811 100755 --- a/examples/17-glossaries.sh +++ b/examples/17-glossaries.sh @@ -11,7 +11,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/18-tm-list.sh b/examples/18-tm-list.sh index a864b92..c762ead 100755 --- a/examples/18-tm-list.sh +++ b/examples/18-tm-list.sh @@ -12,7 +12,7 @@ echo if ! deepl auth show &>/dev/null; then echo "ERROR: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/19-watch-mode.sh b/examples/19-watch-mode.sh index 10f9808..8127e4e 100755 --- a/examples/19-watch-mode.sh +++ b/examples/19-watch-mode.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/20-git-hooks.sh b/examples/20-git-hooks.sh index f776eeb..2e31c3b 100755 --- a/examples/20-git-hooks.sh +++ b/examples/20-git-hooks.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/21-cicd-integration.sh b/examples/21-cicd-integration.sh index 0143fb4..13bf6ae 100755 --- a/examples/21-cicd-integration.sh +++ b/examples/21-cicd-integration.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi @@ -144,7 +144,7 @@ jobs: - name: Configure API Key env: DEEPL_API_KEY: ${{ secrets.DEEPL_API_KEY }} - run: deepl auth set-key "$DEEPL_API_KEY" + run: echo "$DEEPL_API_KEY" | deepl auth set-key --from-stdin - name: Translate Documentation run: | diff --git a/examples/22-sync-basic.sh b/examples/22-sync-basic.sh index f90a045..8b2d76c 100755 --- a/examples/22-sync-basic.sh +++ b/examples/22-sync-basic.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/23-sync-ci.sh b/examples/23-sync-ci.sh index 6212eef..bd14c11 100755 --- a/examples/23-sync-ci.sh +++ b/examples/23-sync-ci.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/24-sync-live-validation.sh b/examples/24-sync-live-validation.sh index b7b2de3..d3fb7c1 100755 --- a/examples/24-sync-live-validation.sh +++ b/examples/24-sync-live-validation.sh @@ -12,7 +12,7 @@ echo # Check API key if ! deepl auth show &>/dev/null; then echo "Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/25-sync-laravel-php.sh b/examples/25-sync-laravel-php.sh index ac1fd4f..a0d9dfb 100755 --- a/examples/25-sync-laravel-php.sh +++ b/examples/25-sync-laravel-php.sh @@ -10,7 +10,7 @@ echo if ! deepl auth show &>/dev/null; then echo "Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/28-configuration.sh b/examples/28-configuration.sh index 21ab906..f42865f 100755 --- a/examples/28-configuration.sh +++ b/examples/28-configuration.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi @@ -99,7 +99,7 @@ echo " To reset config in real usage:" echo " $ deepl config reset" echo echo " After reset, you'll need to set your API key again:" -echo " $ deepl auth set-key YOUR_API_KEY" +echo ' $ echo "YOUR_API_KEY" | deepl auth set-key --from-stdin' echo # ═══════════════════════════════════════════════════════ diff --git a/examples/29-custom-config-files.sh b/examples/29-custom-config-files.sh index 677dce9..808a315 100755 --- a/examples/29-custom-config-files.sh +++ b/examples/29-custom-config-files.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi @@ -125,8 +125,8 @@ echo # Note: In a real scenario, you would set the API key in each config file # For this example, we'll just demonstrate the config switching mechanism echo "💡 Tip: In practice, each config file would have its own API key:" -echo " deepl --config work-config.json auth set-key WORK_API_KEY" -echo " deepl --config personal-config.json auth set-key PERSONAL_API_KEY" +echo " deepl --config work-config.json auth set-key --from-stdin < work-key.txt" +echo " deepl --config personal-config.json auth set-key --from-stdin < personal-key.txt" echo echo "=== 3. Setting Values in Specific Config Files ===" diff --git a/examples/30-cache.sh b/examples/30-cache.sh index ee10735..0136978 100755 --- a/examples/30-cache.sh +++ b/examples/30-cache.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/31-style-rules.sh b/examples/31-style-rules.sh index c080d12..78bf991 100755 --- a/examples/31-style-rules.sh +++ b/examples/31-style-rules.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/32-style-rules-crud.sh b/examples/32-style-rules-crud.sh index 7c4f717..17c684e 100755 --- a/examples/32-style-rules-crud.sh +++ b/examples/32-style-rules-crud.sh @@ -11,7 +11,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/33-usage-monitoring.sh b/examples/33-usage-monitoring.sh index 6d00085..b4fc331 100755 --- a/examples/33-usage-monitoring.sh +++ b/examples/33-usage-monitoring.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/34-languages.sh b/examples/34-languages.sh index 8c72214..2fb1daf 100755 --- a/examples/34-languages.sh +++ b/examples/34-languages.sh @@ -11,7 +11,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/35-detect.sh b/examples/35-detect.sh index d4790a8..4384711 100755 --- a/examples/35-detect.sh +++ b/examples/35-detect.sh @@ -9,7 +9,7 @@ echo if ! deepl auth show &>/dev/null; then echo "Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/37-admin.sh b/examples/37-admin.sh index 9fa894b..56ad5bc 100755 --- a/examples/37-admin.sh +++ b/examples/37-admin.sh @@ -13,7 +13,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/38-init.sh b/examples/38-init.sh index 7ef4b26..4112b82 100755 --- a/examples/38-init.sh +++ b/examples/38-init.sh @@ -62,12 +62,12 @@ echo echo "The init wizard requires a terminal. For scripts and CI/CD," echo "use individual commands instead:" echo -echo " # Set API key directly" -echo " deepl auth set-key YOUR_API_KEY" -echo -echo " # Or from stdin (for CI/CD pipelines)" +echo " # Set API key from stdin" echo ' echo "$DEEPL_API_KEY" | deepl auth set-key --from-stdin' echo +echo " # Or from a file, keeping the key out of shell history" +echo " deepl auth set-key --from-stdin < keyfile" +echo echo " # Set default target language" echo " deepl config set defaults.targetLangs es" echo diff --git a/examples/39-advanced-translate.sh b/examples/39-advanced-translate.sh index 51f0e17..9abff5e 100755 --- a/examples/39-advanced-translate.sh +++ b/examples/39-advanced-translate.sh @@ -10,7 +10,7 @@ echo # Check if API key is configured if ! deepl auth show &>/dev/null; then echo "❌ Error: API key not configured" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/examples/README.md b/examples/README.md index 45f16e1..2b66d4a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -85,7 +85,7 @@ needs neither an API key nor a network. All examples assume you have: 1. Installed DeepL CLI (`npm install -g @deepl/cli` or `npm link`) -2. A DeepL API key configured (`deepl auth set-key YOUR_API_KEY`) +2. A DeepL API key configured (`deepl init`) ## Running Examples diff --git a/examples/run-all.sh b/examples/run-all.sh index 7d2e782..9c0509b 100755 --- a/examples/run-all.sh +++ b/examples/run-all.sh @@ -12,7 +12,7 @@ echo # Check API key first if ! deepl auth show &>/dev/null; then echo "❌ Error: API key required to run examples" - echo "Run: deepl auth set-key YOUR_API_KEY" + echo "Run: deepl init, or: deepl auth set-key --from-stdin < keyfile" exit 1 fi diff --git a/src/cli/commands/register-auth.ts b/src/cli/commands/register-auth.ts index 9e91f56..b2eb89d 100644 --- a/src/cli/commands/register-auth.ts +++ b/src/cli/commands/register-auth.ts @@ -24,59 +24,64 @@ export function registerAuth( Examples: $ echo "YOUR_API_KEY" | deepl auth set-key --from-stdin $ deepl auth set-key --from-stdin < ~/.deepl-api-key - $ deepl auth set-key YOUR_API_KEY $ deepl auth show $ deepl auth clear -Note: Prefer --from-stdin over passing the key as an argument. -Command arguments are visible to other users via process listings. +Note: The key is read from stdin only. A command-line argument would be visible +to other users via process listings and recorded in shell history. Run deepl init +to be prompted for it instead. ` ) .addCommand( new Command('set-key') - .description('Set your DeepL API key') - .argument('[api-key]', 'Your DeepL API key (or pipe via stdin)') - .option('--from-stdin', 'Read API key from stdin') + .description('Set your DeepL API key (read from stdin)') + // Excess arguments are accepted by the parser so that a key passed as + // one can be refused by name here. Commander's own excess-argument + // error quotes the offending value, which would print the key. + .allowExcessArguments(true) + .option('--from-stdin', 'Read API key from stdin (the only source)') .option( '--no-verify', 'Store the key without validating it against the API (for offline or proxied networks)' ) .action( async ( - apiKey: string | undefined, - opts: { fromStdin?: boolean; verify?: boolean } + opts: { fromStdin?: boolean; verify?: boolean }, + command: Command ) => { try { - let key = apiKey; - if (apiKey && !opts.fromStdin) { - Logger.warn( - 'Passing API key as argument is deprecated (visible in ps). Use --from-stdin instead.' + if (command.args.length > 0) { + handleError( + new ValidationError( + 'The API key can no longer be passed as an argument.', + 'Pipe it in instead: deepl auth set-key --from-stdin < keyfile, or run deepl init to be prompted for it.' + ) ); + return; } - if (opts.fromStdin === true || !key) { - if (process.stdin.isTTY && !key) { - handleError( - new ValidationError( - 'API key required: provide as argument or use --from-stdin' - ) + if (process.stdin.isTTY) { + handleError( + new ValidationError( + 'API key required on stdin.', + 'Pipe it in: echo "YOUR_API_KEY" | deepl auth set-key --from-stdin, or run deepl init to be prompted for it.' + ) + ); + return; + } + const MAX_STDIN_BYTES = 131072; // 128KB + const chunks: Buffer[] = []; + let totalBytes = 0; + for await (const chunk of process.stdin) { + const buf = chunk as Buffer; + totalBytes += buf.length; + if (totalBytes > MAX_STDIN_BYTES) { + throw new ValidationError( + 'Input exceeds maximum size of 128KB' ); - return; - } - const MAX_STDIN_BYTES = 131072; // 128KB - const chunks: Buffer[] = []; - let totalBytes = 0; - for await (const chunk of process.stdin) { - const buf = chunk as Buffer; - totalBytes += buf.length; - if (totalBytes > MAX_STDIN_BYTES) { - throw new ValidationError( - 'Input exceeds maximum size of 128KB' - ); - } - chunks.push(buf); } - key = Buffer.concat(chunks).toString('utf-8').trim(); + chunks.push(buf); } + const key = Buffer.concat(chunks).toString('utf-8').trim(); const { AuthCommand } = await import('./auth.js'); const authCommand = new AuthCommand( getConfigService(), diff --git a/src/cli/commands/register-init.ts b/src/cli/commands/register-init.ts index c69ea4f..6aba5fb 100644 --- a/src/cli/commands/register-init.ts +++ b/src/cli/commands/register-init.ts @@ -28,7 +28,7 @@ Examples: try { if (isNoInput() || !process.stdin.isTTY) { throw new ValidationError( - 'init is not supported in non-interactive mode. Use deepl auth set-key to configure authentication.' + 'init is not supported in non-interactive mode. Use deepl auth set-key --from-stdin to configure authentication.' ); } const { InitCommand } = await import('./init.js'); diff --git a/src/cli/commands/register-languages.ts b/src/cli/commands/register-languages.ts index 4048c2d..005f940 100644 --- a/src/cli/commands/register-languages.ts +++ b/src/cli/commands/register-languages.ts @@ -103,7 +103,7 @@ Examples: ); Logger.warn( chalk.yellow( - 'Run: deepl auth set-key for API-verified names.\n' + 'Run: deepl init, or deepl auth set-key --from-stdin < keyfile, for API-verified names.\n' ) ); if (showFeatures) { diff --git a/src/cli/commands/sync/register-sync-push.ts b/src/cli/commands/sync/register-sync-push.ts index bd2d00f..f2ea93c 100644 --- a/src/cli/commands/sync/register-sync-push.ts +++ b/src/cli/commands/sync/register-sync-push.ts @@ -40,9 +40,9 @@ Requires TMS integration. Add a tms: block to .deepl-sync.yaml: server: https://tms.example.com project_id: my-project -Credentials: prefer the TMS_API_KEY (or TMS_TOKEN) env var over inlining -'api_key'/'token' in the YAML. See docs/SYNC.md#tms-rest-contract for the -full field list and REST contract. +Credentials come from the TMS_API_KEY (or TMS_TOKEN) env var. They cannot be +set in the YAML, which is a committed file. See docs/SYNC.md#tms-rest-contract +for the full field list and REST contract. ` ) .action((options: PushOptions, command: Command) => diff --git a/src/cli/commands/translate/text-translation-handler.ts b/src/cli/commands/translate/text-translation-handler.ts index 9b007dc..6da5f0b 100644 --- a/src/cli/commands/translate/text-translation-handler.ts +++ b/src/cli/commands/translate/text-translation-handler.ts @@ -45,7 +45,7 @@ export class TextTranslationHandler { const envKey = process.env['DEEPL_API_KEY']; if (!apiKey && !envKey) { throw new AuthError( - 'API key not set. Run: deepl auth set-key ' + 'API key not set. Run: deepl init, or deepl auth set-key --from-stdin < keyfile' ); } diff --git a/src/storage/config.ts b/src/storage/config.ts index 12cdb71..e75106f 100644 --- a/src/storage/config.ts +++ b/src/storage/config.ts @@ -333,7 +333,7 @@ export class ConfigService { repairPrivateFileMode( this.configPath, 0o600, - 'Your API key may already have been read; consider rotating it with: deepl auth set-key' + 'Your API key may already have been read; consider rotating it with: deepl auth set-key --from-stdin' ); warnOnWritableDirectory(path.dirname(this.configPath)); } catch { diff --git a/src/sync/sync-config.ts b/src/sync/sync-config.ts index fabe55d..62748e1 100644 --- a/src/sync/sync-config.ts +++ b/src/sync/sync-config.ts @@ -166,12 +166,22 @@ const KNOWN_TMS_KEYS: readonly string[] = [ 'enabled', 'server', 'project_id', - 'api_key', - 'token', 'timeout_ms', 'push_concurrency', ]; +/** + * `tms:` credential fields that were implemented and are no longer read. + * `.deepl-sync.yaml` is a committed file, so a credential inlined in it is a + * secret in version control; the environment variables are the only source. + * Rejected by name so the message can say where the value belongs instead, + * and without quoting the value, which would print the credential. + */ +const REMOVED_TMS_CREDENTIAL_KEYS: Readonly> = { + api_key: 'TMS_API_KEY', + token: 'TMS_TOKEN', +}; + /** * `tms:` fields the schema once accepted, and documented, without any code * reading them. They are rejected by name rather than as unknown fields, @@ -602,6 +612,13 @@ export function validateSyncConfig(raw: unknown): SyncConfig { } if (obj['tms'] !== undefined) { const tmsBlock = obj['tms'] as Record; + for (const [key, envVar] of Object.entries(REMOVED_TMS_CREDENTIAL_KEYS)) { + if (tmsBlock[key] === undefined) continue; + throw new ConfigError( + `tms.${key} is no longer read from .deepl-sync.yaml`, + `Remove it and set the ${envVar} environment variable instead. This file is committed, so rotate the credential if it has ever been pushed.` + ); + } for (const [key, alternative] of Object.entries(RETIRED_TMS_KEYS)) { if (tmsBlock[key] === undefined) continue; throw new ConfigError( @@ -810,7 +827,6 @@ export async function loadSyncConfig( } const config = applyCliOverrides(validateSyncConfig(parsed), overrides); - warnOnInlineTmsCredentials(config.tms); return { ...config, @@ -819,22 +835,3 @@ export async function loadSyncConfig( overrides, }; } - -// Emit a security warning whenever a user has put TMS credentials directly -// in .deepl-sync.yaml instead of the recommended env vars. Writes directly -// to stderr (no TTY gate) so the warning survives on CI and piped contexts. -// Runs at config load so every sync subcommand warns, not just the ones -// that resolve TMS credentials in tms-client.ts. -function warnOnInlineTmsCredentials(tms: SyncTmsConfig | undefined): void { - if (!tms) return; - if (tms.api_key && !process.env['TMS_API_KEY']) { - process.stderr.write( - 'Warning: TMS api_key is set in .deepl-sync.yaml. Use the TMS_API_KEY env var instead to avoid committing secrets.\n' - ); - } - if (tms.token && !process.env['TMS_TOKEN']) { - process.stderr.write( - 'Warning: TMS token is set in .deepl-sync.yaml. Use the TMS_TOKEN env var instead to avoid committing secrets.\n' - ); - } -} diff --git a/src/sync/tms-client.ts b/src/sync/tms-client.ts index eca37b6..b3b9825 100644 --- a/src/sync/tms-client.ts +++ b/src/sync/tms-client.ts @@ -471,49 +471,23 @@ export class TmsClient { } } -export function resolveTmsCredentials(config: { - api_key?: string; +export function resolveTmsCredentials(): { + apiKey?: string; token?: string; -}): { apiKey?: string; token?: string; source: TmsCredentialSource } { - const apiKey = process.env['TMS_API_KEY'] ?? config.api_key; - const token = process.env['TMS_TOKEN'] ?? config.token; - - if (!process.env['TMS_API_KEY'] && config.api_key) { - Logger.warn( - 'Warning: TMS API key found in config file. Use TMS_API_KEY env var instead to avoid committing secrets.' - ); - } - if (!process.env['TMS_TOKEN'] && config.token) { - Logger.warn( - 'Warning: TMS token found in config file. Use TMS_TOKEN env var instead to avoid committing secrets.' - ); - } - - return { apiKey, token, source: credentialSource(apiKey, token) }; -} - -/** - * Where the credential that `getAuthHeader` will actually attach came from. - * It prefers apiKey, so an env-held token behind a config-held api_key is - * never sent and does not make the destination a leak. - */ -function credentialSource( - apiKey: string | undefined, - token: string | undefined -): TmsCredentialSource { - if (apiKey) return process.env['TMS_API_KEY'] ? 'env' : 'config'; - if (token) return process.env['TMS_TOKEN'] ? 'env' : 'config'; - return 'none'; + source: TmsCredentialSource; +} { + const apiKey = process.env['TMS_API_KEY']; + const token = process.env['TMS_TOKEN']; + const source: TmsCredentialSource = + Boolean(apiKey) || Boolean(token) ? 'env' : 'none'; + return { apiKey, token, source }; } export async function createTmsClient( config: SyncTmsConfig, trustDeps: TmsServerTrustDeps = {} ): Promise { - const { apiKey, token, source } = resolveTmsCredentials({ - api_key: config.api_key, - token: config.token, - }); + const { apiKey, token, source } = resolveTmsCredentials(); await ensureTmsServerApproved(config.server, source, trustDeps); return new TmsClient({ serverUrl: config.server, diff --git a/src/sync/tms-server-trust.ts b/src/sync/tms-server-trust.ts index 444e36e..6ec88eb 100644 --- a/src/sync/tms-server-trust.ts +++ b/src/sync/tms-server-trust.ts @@ -17,7 +17,7 @@ import { ConfigService } from '../storage/config.js'; export const ALLOWED_SERVERS_CONFIG_KEY = 'tms.allowedServers'; -export type TmsCredentialSource = 'env' | 'config' | 'none'; +export type TmsCredentialSource = 'env' | 'none'; export interface TmsServerTrustDeps { readAllowedServers?: () => string[]; diff --git a/src/sync/types.ts b/src/sync/types.ts index 631d3cf..9e2e1b9 100644 --- a/src/sync/types.ts +++ b/src/sync/types.ts @@ -100,8 +100,6 @@ export interface SyncTmsConfig { enabled: boolean; server: string; project_id: string; - api_key?: string; - token?: string; timeout_ms?: number; push_concurrency?: number; } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index af09e98..7b07dc0 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -24,7 +24,7 @@ export class AuthError extends DeepLCLIError { super( message, suggestion ?? - 'Run: deepl init (setup wizard) or deepl auth set-key ' + 'Run: deepl init (setup wizard), or pipe a key: deepl auth set-key --from-stdin < keyfile' ); } } diff --git a/src/utils/paths.ts b/src/utils/paths.ts index 79f1df7..1a946c3 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -105,14 +105,18 @@ export function resolvePaths(): ResolvedPaths { }; } - // 2. Legacy ~/.deepl-cli/ exists on disk + // 2. Legacy ~/.deepl-cli/, when it actually holds a config or cache file. + // A bare directory does not count: an empty one would otherwise capture + // resolution and make an explicitly set XDG_CONFIG_HOME inert. const legacyDir = path.join(home, '.deepl-cli'); - if (fs.existsSync(legacyDir)) { + const legacyConfigFile = path.join(legacyDir, 'config.json'); + const legacyCacheFile = path.join(legacyDir, 'cache.db'); + if (fs.existsSync(legacyConfigFile) || fs.existsSync(legacyCacheFile)) { return { configDir: legacyDir, - configFile: path.join(legacyDir, 'config.json'), + configFile: legacyConfigFile, cacheDir: legacyDir, - cacheFile: path.join(legacyDir, 'cache.db'), + cacheFile: legacyCacheFile, }; } diff --git a/tests/e2e/cli-auth.e2e.test.ts b/tests/e2e/cli-auth.e2e.test.ts index d5006a1..5a37779 100644 --- a/tests/e2e/cli-auth.e2e.test.ts +++ b/tests/e2e/cli-auth.e2e.test.ts @@ -7,10 +7,8 @@ import { createTestConfigDir, makeNodeRunCLI } from '../helpers'; describe('Auth Command E2E', () => { const testConfig = createTestConfigDir('e2e-auth'); - const { runCLI, runCLIAll, runCLIExpectError } = makeNodeRunCLI( - testConfig.path, - { excludeApiKey: true } - ); + const { runCLI, runCLIAll, runCLIExpectError, runCLIWithStdin } = + makeNodeRunCLI(testConfig.path, { excludeApiKey: true }); afterAll(() => { testConfig.cleanup(); @@ -67,7 +65,7 @@ describe('Auth Command E2E', () => { describe('auth set-key', () => { it('should reject empty API key', () => { - const result = runCLIExpectError('auth set-key ""'); + const result = runCLIWithStdin('auth set-key --from-stdin', '\n'); expect(result.status).toBeGreaterThan(0); expect(result.output).toContain('API key cannot be empty'); @@ -76,11 +74,23 @@ describe('Auth Command E2E', () => { // set-key validates against the API before persisting, so a // well-formed-but-wrong key is refused by the service rather than locally. it('should reject a key the API does not accept', () => { - const result = runCLIExpectError('auth set-key invalid-key-123'); + const result = runCLIWithStdin( + 'auth set-key --from-stdin', + 'invalid-key-123\n' + ); expect(result.status).toBeGreaterThan(0); expect(result.output).toContain('Authentication failed'); }); + + it('refuses a key passed as an argument without echoing it', () => { + const result = runCLIExpectError('auth set-key invalid-key-123'); + + expect(result.status).toBe(6); + expect(result.output).toContain('no longer be passed as an argument'); + expect(result.output).toContain('--from-stdin'); + expect(result.output).not.toContain('invalid-key-123'); + }); }); describe('auth command structure', () => { diff --git a/tests/e2e/cli-sync-push-pull.e2e.test.ts b/tests/e2e/cli-sync-push-pull.e2e.test.ts index c41c191..3b97682 100644 --- a/tests/e2e/cli-sync-push-pull.e2e.test.ts +++ b/tests/e2e/cli-sync-push-pull.e2e.test.ts @@ -373,7 +373,7 @@ describe('CLI sync push/pull dispatch E2E', () => { ); }); - it('push: a credential inlined in the repo YAML is not gated', async () => { + it('push: a credential inlined in the repo YAML is refused at config load', async () => { const yaml = buildSyncConfigYaml({ targetLocales: ['de'], buckets: { json: { include: ['locales/en.json'] } }, @@ -389,12 +389,17 @@ describe('CLI sync push/pull dispatch E2E', () => { writeTarget('de', { greeting: 'Hallo' }); await configureMockServer({}); - const run = runCli(['sync', 'push']); + const run = runCli(['sync', 'push', '--format', 'json']); - expect(run.status).toBe(0); + expect(run.status).toBe(7); + const envelope = assertErrorEnvelope(run.stdout, 'ConfigError', 7); + expect(envelope.error.message).toContain('tms.api_key'); + expect(envelope.error.suggestion).toContain('TMS_API_KEY'); + expect( + `${envelope.error.message} ${envelope.error.suggestion ?? ''}` + ).not.toContain('inlined-in-repo'); const state = await inspectMockServer(); - expect(state.requests.length).toBeGreaterThan(0); - expect(state.requests[0]!.authHeader).toBe('ApiKey inlined-in-repo'); + expect(state.requests.length).toBe(0); }); }); diff --git a/tests/integration/sync-tms.integration.test.ts b/tests/integration/sync-tms.integration.test.ts index c2f5b72..bc09076 100644 --- a/tests/integration/sync-tms.integration.test.ts +++ b/tests/integration/sync-tms.integration.test.ts @@ -335,8 +335,8 @@ describe('sync push/pull (TMS integration)', () => { ); // ---- Case 3: TMS_API_KEY env var precedence over config ---- - it('credential resolution: TMS_API_KEY env var overrides config.api_key', async () => { - writeSyncConfig(tmpDir, { tms: tmsConfig({ api_key: 'from-config' }) }); + it('credential resolution: TMS_API_KEY env var supplies the api key', async () => { + writeSyncConfig(tmpDir, { tms: tmsConfig() }); writeJson(tmpDir, 'locales/en.json', { k: 'Hello' }); writeJson(tmpDir, 'locales/de.json', { k: 'Hallo' }); @@ -373,20 +373,19 @@ describe('sync push/pull (TMS integration)', () => { }); // ---- Case 5: secret-in-config warning ---- - it('credential resolution: emits a stderr warning when api_key is sourced from .deepl-sync.yaml', async () => { + it('credential resolution: refuses an api_key written into .deepl-sync.yaml', async () => { writeSyncConfig(tmpDir, { tms: tmsConfig({ api_key: 'in-config' }) }); - const warn = jest - .spyOn(console, 'error') - .mockImplementation(() => undefined); + expect.assertions(3); try { - const config = await loadSyncConfig(tmpDir); - await createTmsClient(config.tms!, approvedTmsTrust); - expect(warn).toHaveBeenCalledWith( - expect.stringMatching(/TMS API key found in config file.*TMS_API_KEY/) + await loadSyncConfig(tmpDir); + } catch (error) { + const err = error as ConfigError; + expect(err).toBeInstanceOf(ConfigError); + expect(err.suggestion).toMatch(/TMS_API_KEY/); + expect(`${err.message} ${err.suggestion ?? ''}`).not.toContain( + 'in-config' ); - } finally { - warn.mockRestore(); } }); diff --git a/tests/unit/auth-command.test.ts b/tests/unit/auth-command.test.ts index 360358e..445ec49 100644 --- a/tests/unit/auth-command.test.ts +++ b/tests/unit/auth-command.test.ts @@ -7,7 +7,8 @@ import { AuthCommand } from '../../src/cli/commands/auth'; import { ConfigService } from '../../src/storage/config'; import { DeepLClient } from '../../src/api/deepl-client'; import { createMockConfigService } from '../helpers/mock-factories'; -import { NetworkError } from '../../src/utils/errors'; +import { NetworkError, ValidationError } from '../../src/utils/errors'; +import { Readable } from 'stream'; // Mock chalk (ESM-only) jest.mock('chalk', () => { @@ -24,7 +25,7 @@ jest.mock('chalk', () => { return { __esModule: true, default: obj }; }); -// Mock Logger for registerAuth deprecation tests +// Mock Logger for the registerAuth tests jest.mock('../../src/utils/logger', () => ({ Logger: { info: jest.fn(), @@ -296,7 +297,23 @@ describe('AuthCommand', () => { }); }); -describe('registerAuth - deprecation warning', () => { +/** + * Feeds `content` to the action's stdin read and reports non-TTY, so the + * set-key path under test behaves as it does behind a pipe. + */ +function withStdin(content: string): () => void { + const original = Object.getOwnPropertyDescriptor(process, 'stdin'); + const stream = Readable.from([Buffer.from(content)]); + Object.defineProperty(process, 'stdin', { + value: stream, + configurable: true, + }); + return () => { + if (original) Object.defineProperty(process, 'stdin', original); + }; +} + +describe('registerAuth - set-key', () => { // Dynamic import to avoid hoisting issues with chalk mock let registerAuth: typeof import('../../src/cli/commands/register-auth').registerAuth; @@ -309,7 +326,39 @@ describe('registerAuth - deprecation warning', () => { jest.clearAllMocks(); }); - it('should warn when positional API key is passed without --from-stdin', async () => { + it('rejects an API key passed as an argument without echoing it', async () => { + const mockConfigService = createMockConfigService(); + const program = new Command(); + program.exitOverride(); + registerAuth(program, { + getConfigService: () => mockConfigService, + handleError: (error: unknown) => { + throw error; + }, + }); + + expect.assertions(5); + try { + await program.parseAsync([ + 'node', + 'deepl', + 'auth', + 'set-key', + 'test-key-123', + ]); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const err = error as ValidationError; + expect(err.exitCode).toBe(6); + // The whole point of rejecting by name rather than letting commander + // report an excess argument: commander's message quotes the value. + expect(err.message).not.toContain('test-key-123'); + expect(err.suggestion ?? '').not.toContain('test-key-123'); + expect(mockConfigService.set).not.toHaveBeenCalled(); + } + }); + + it('reads the key from stdin behind a pipe', async () => { const mockConfigService = createMockConfigService(); const mockGetUsage = jest .fn() @@ -330,18 +379,44 @@ describe('registerAuth - deprecation warning', () => { }, }); - await program.parseAsync([ - 'node', - 'deepl', - 'auth', - 'set-key', - 'test-key-123', - ]); + const restore = withStdin('test-key-123\n'); + try { + await program.parseAsync(['node', 'deepl', 'auth', 'set-key']); + } finally { + restore(); + } - const { Logger } = await import('../../src/utils/logger'); - expect(Logger.warn).toHaveBeenCalledWith( - expect.stringContaining('deprecated') - ); + expect(DeepLClient).toHaveBeenCalledWith('test-key-123', expect.anything()); + }); + + it('refuses on a terminal, naming the two supported paths', async () => { + const mockConfigService = createMockConfigService(); + const program = new Command(); + program.exitOverride(); + registerAuth(program, { + getConfigService: () => mockConfigService, + handleError: (error: unknown) => { + throw error; + }, + }); + + const original = Object.getOwnPropertyDescriptor(process, 'stdin'); + Object.defineProperty(process, 'stdin', { + value: { isTTY: true }, + configurable: true, + }); + + expect.assertions(3); + try { + await program.parseAsync(['node', 'deepl', 'auth', 'set-key']); + } catch (error) { + const err = error as ValidationError; + expect(err).toBeInstanceOf(ValidationError); + expect(err.suggestion ?? '').toContain('--from-stdin'); + expect(err.suggestion ?? '').toContain('deepl init'); + } finally { + if (original) Object.defineProperty(process, 'stdin', original); + } }); it('should thread getHttpOptions into the key-validation client', async () => { @@ -366,13 +441,12 @@ describe('registerAuth - deprecation warning', () => { }, }); - await program.parseAsync([ - 'node', - 'deepl', - 'auth', - 'set-key', - 'test-key-123', - ]); + const restore = withStdin('test-key-123\n'); + try { + await program.parseAsync(['node', 'deepl', 'auth', 'set-key']); + } finally { + restore(); + } expect(DeepLClient).toHaveBeenCalledWith( 'test-key-123', diff --git a/tests/unit/docs/documented-surface.test.ts b/tests/unit/docs/documented-surface.test.ts index 3ff4f97..00ccbb2 100644 --- a/tests/unit/docs/documented-surface.test.ts +++ b/tests/unit/docs/documented-surface.test.ts @@ -209,12 +209,20 @@ describe('documented CLI surface', () => { return tokens; } + /** + * Every `deepl …` run a reader could copy: whole lines in fenced blocks, and + * inline-code spans in prose, which a line-start test never sees. + */ function invocations(markdown: string): string[] { - return markdown + const wholeLines = markdown .split('\n') .map((line) => line.replace(/^\s*[$>]\s*/, '').trim()) .filter((line) => line.startsWith('deepl ')) .map((line) => line.slice('deepl '.length)); + const inlineCode = [...markdown.matchAll(/`deepl\s+([^`\n]+)`/g)].map( + (match) => match[1]!.trim() + ); + return [...wholeLines, ...inlineCode]; } describe.each(DOCS)('%s', (docPath) => { diff --git a/tests/unit/error-suggestions.test.ts b/tests/unit/error-suggestions.test.ts index 7e54724..4a8ad6b 100644 --- a/tests/unit/error-suggestions.test.ts +++ b/tests/unit/error-suggestions.test.ts @@ -32,7 +32,7 @@ describe('Error suggestions', () => { it('should have a default suggestion about setting the API key', () => { const error = new AuthError('Authentication failed'); expect(error.suggestion).toBe( - 'Run: deepl init (setup wizard) or deepl auth set-key ' + 'Run: deepl init (setup wizard), or pipe a key: deepl auth set-key --from-stdin < keyfile' ); }); diff --git a/tests/unit/paths.test.ts b/tests/unit/paths.test.ts index b28b630..98fd6d1 100644 --- a/tests/unit/paths.test.ts +++ b/tests/unit/paths.test.ts @@ -45,9 +45,11 @@ describe('resolvePaths', () => { expect(paths.configDir).toBe(path.join(HOME, '.config', 'deepl-cli')); }); - it('uses legacy ~/.deepl-cli/ when it exists', () => { + it('uses legacy ~/.deepl-cli/ when it holds a config file', () => { const legacyDir = path.join(HOME, '.deepl-cli'); - mockExistsSync.mockImplementation((p) => p === legacyDir); + mockExistsSync.mockImplementation( + (p) => p === path.join(legacyDir, 'config.json') + ); const paths = resolvePaths(); @@ -112,7 +114,9 @@ describe('resolvePaths', () => { it('prefers legacy dir over XDG vars', () => { const legacyDir = path.join(HOME, '.deepl-cli'); - mockExistsSync.mockImplementation((p) => p === legacyDir); + mockExistsSync.mockImplementation( + (p) => p === path.join(legacyDir, 'config.json') + ); process.env['XDG_CONFIG_HOME'] = '/xdg/config'; process.env['XDG_CACHE_HOME'] = '/xdg/cache'; @@ -124,7 +128,9 @@ describe('resolvePaths', () => { it('prefers DEEPL_CONFIG_DIR over legacy dir', () => { const legacyDir = path.join(HOME, '.deepl-cli'); - mockExistsSync.mockImplementation((p) => p === legacyDir); + mockExistsSync.mockImplementation( + (p) => p === path.join(legacyDir, 'config.json') + ); process.env['DEEPL_CONFIG_DIR'] = '/override'; const paths = resolvePaths(); @@ -132,4 +138,39 @@ describe('resolvePaths', () => { expect(paths.configDir).toBe('/override'); expect(paths.cacheDir).toBe('/override'); }); + it('ignores an empty legacy dir and honours explicit XDG vars', () => { + const legacyDir = path.join(HOME, '.deepl-cli'); + mockExistsSync.mockImplementation((p) => p === legacyDir); + process.env['XDG_CONFIG_HOME'] = '/xdg/config'; + process.env['XDG_CACHE_HOME'] = '/xdg/cache'; + + const paths = resolvePaths(); + + expect(paths.configDir).toBe('/xdg/config/deepl-cli'); + expect(paths.cacheDir).toBe('/xdg/cache/deepl-cli'); + }); + + it('ignores an empty legacy dir and falls back to XDG defaults', () => { + const legacyDir = path.join(HOME, '.deepl-cli'); + mockExistsSync.mockImplementation((p) => p === legacyDir); + + const paths = resolvePaths(); + + expect(paths.configDir).toBe(path.join(HOME, '.config', 'deepl-cli')); + expect(paths.cacheDir).toBe(path.join(HOME, '.cache', 'deepl-cli')); + }); + + it('uses legacy ~/.deepl-cli/ when it holds only a cache file', () => { + const legacyDir = path.join(HOME, '.deepl-cli'); + mockExistsSync.mockImplementation( + (p) => p === path.join(legacyDir, 'cache.db') + ); + process.env['XDG_CONFIG_HOME'] = '/xdg/config'; + + const paths = resolvePaths(); + + expect(paths.configDir).toBe(legacyDir); + expect(paths.configFile).toBe(path.join(legacyDir, 'config.json')); + expect(paths.cacheFile).toBe(path.join(legacyDir, 'cache.db')); + }); }); diff --git a/tests/unit/register-commands-group1.test.ts b/tests/unit/register-commands-group1.test.ts index dc0fe8b..c64f341 100644 --- a/tests/unit/register-commands-group1.test.ts +++ b/tests/unit/register-commands-group1.test.ts @@ -498,15 +498,34 @@ describe('registerAuth', () => { ); }); + /** Swap process.stdin for a readable carrying `chunks`. */ + async function withStdin( + chunks: Buffer[], + isTTY: boolean, + run: () => Promise + ): Promise { + const original = Object.getOwnPropertyDescriptor(process, 'stdin')!; + const replacement = Readable.from(chunks); + Object.defineProperty(replacement, 'isTTY', { + value: isTTY, + configurable: true, + }); + Object.defineProperty(process, 'stdin', { + value: replacement, + configurable: true, + }); + try { + await run(); + } finally { + Object.defineProperty(process, 'stdin', original); + } + } + it('auth set-key should save and report success', async () => { await loadAndRegister(); - await program.parseAsync([ - 'node', - 'test', - 'auth', - 'set-key', - 'my-api-key-12345', - ]); + await withStdin([Buffer.from('my-api-key-12345\n')], false, async () => { + await program.parseAsync(['node', 'test', 'auth', 'set-key']); + }); expect(mockSetKey).toHaveBeenCalledWith('my-api-key-12345', { verify: true, @@ -521,23 +540,26 @@ describe('registerAuth', () => { mockSetKey.mockRejectedValue(setKeyError); await loadAndRegister(); - await expect( - program.parseAsync(['node', 'test', 'auth', 'set-key', 'bad-key']) - ).rejects.toThrow('Validation failed'); + await withStdin([Buffer.from('bad-key\n')], false, async () => { + await expect( + program.parseAsync(['node', 'test', 'auth', 'set-key']) + ).rejects.toThrow('Validation failed'); + }); expect(handleError).toHaveBeenCalledWith(setKeyError); }); it('auth set-key --no-verify should save without contacting the API', async () => { await loadAndRegister(); - await program.parseAsync([ - 'node', - 'test', - 'auth', - 'set-key', - 'my-api-key-12345', - '--no-verify', - ]); + await withStdin([Buffer.from('my-api-key-12345\n')], false, async () => { + await program.parseAsync([ + 'node', + 'test', + 'auth', + 'set-key', + '--no-verify', + ]); + }); expect(mockSetKey).toHaveBeenCalledWith('my-api-key-12345', { verify: false, @@ -548,29 +570,6 @@ describe('registerAuth', () => { }); describe('auth set-key reading from stdin', () => { - /** Swap process.stdin for a readable carrying `chunks`. */ - async function withStdin( - chunks: Buffer[], - isTTY: boolean, - run: () => Promise - ): Promise { - const original = Object.getOwnPropertyDescriptor(process, 'stdin')!; - const replacement = Readable.from(chunks); - Object.defineProperty(replacement, 'isTTY', { - value: isTTY, - configurable: true, - }); - Object.defineProperty(process, 'stdin', { - value: replacement, - configurable: true, - }); - try { - await run(); - } finally { - Object.defineProperty(process, 'stdin', original); - } - } - it('should read the key from stdin and trim it', async () => { await loadAndRegister(); diff --git a/tests/unit/sync/sync-config.test.ts b/tests/unit/sync/sync-config.test.ts index cc5f22c..a36507b 100644 --- a/tests/unit/sync/sync-config.test.ts +++ b/tests/unit/sync/sync-config.test.ts @@ -1169,7 +1169,7 @@ describe('sync-config', () => { expect(() => validateSyncConfig(raw)).toThrow(/translation/); }); - it('should reject unknown tms field and suggest api_key for apikey typo', () => { + it('should reject an apikey typo as an unknown tms field', () => { const raw = { ...baseConfig, tms: { @@ -1188,7 +1188,7 @@ describe('sync-config', () => { /Unknown field "apikey"/ ); expect((err as ConfigError).message).toMatch(/tms/); - expect((err as ConfigError).suggestion).toMatch(/api_key/); + expect((err as ConfigError).suggestion).toMatch(/Remove "apikey"/); } }); @@ -1302,8 +1302,6 @@ describe('sync-config', () => { enabled: true, server: 'https://example.com', project_id: 'test', - api_key: 'secret', - token: 'bearer', timeout_ms: 30000, }, }; @@ -1884,35 +1882,26 @@ translation: }); }); - describe('inline TMS credential warning', () => { + describe('inline TMS credential rejection', () => { let tmpDir: string; - let originalIsTTY: boolean | undefined; let originalApiKey: string | undefined; let originalToken: string | undefined; - let stderrSpy: jest.SpyInstance; beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-tms-warn-')); - originalIsTTY = process.stderr.isTTY; + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-tms-cred-')); originalApiKey = process.env['TMS_API_KEY']; originalToken = process.env['TMS_TOKEN']; delete process.env['TMS_API_KEY']; delete process.env['TMS_TOKEN']; - stderrSpy = jest - .spyOn(process.stderr, 'write') - .mockImplementation(() => true); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); - Object.defineProperty(process.stderr, 'isTTY', { - value: originalIsTTY, - configurable: true, - }); if (originalApiKey !== undefined) process.env['TMS_API_KEY'] = originalApiKey; + else delete process.env['TMS_API_KEY']; if (originalToken !== undefined) process.env['TMS_TOKEN'] = originalToken; - stderrSpy.mockRestore(); + else delete process.env['TMS_TOKEN']; }); const writeConfig = (extra: string): string => { @@ -1932,72 +1921,52 @@ ${extra} return configPath; }; - it('emits a stderr warning when tms.api_key is inlined and stderr is not a TTY', async () => { - Object.defineProperty(process.stderr, 'isTTY', { - value: false, - configurable: true, - }); + it('rejects tms.api_key without echoing the credential', async () => { writeConfig(' api_key: secret-key'); - await loadSyncConfig(tmpDir); - - const writes = stderrSpy.mock.calls.map((c) => String(c[0])).join(''); - expect(writes).toMatch(/TMS_API_KEY/); - expect(writes).toMatch(/\.deepl-sync\.yaml/); + expect.assertions(5); + try { + await loadSyncConfig(tmpDir); + } catch (error) { + const err = error as ConfigError; + expect(err).toBeInstanceOf(ConfigError); + expect(err.exitCode).toBe(7); + expect(err.message).toMatch(/tms\.api_key/); + expect(err.suggestion).toMatch(/TMS_API_KEY/); + expect(`${err.message} ${err.suggestion ?? ''}`).not.toContain( + 'secret-key' + ); + } }); - it('emits a stderr warning when tms.token is inlined and stderr is not a TTY', async () => { - Object.defineProperty(process.stderr, 'isTTY', { - value: false, - configurable: true, - }); + it('rejects tms.token without echoing the credential', async () => { writeConfig(' token: secret-token'); - await loadSyncConfig(tmpDir); - - const writes = stderrSpy.mock.calls.map((c) => String(c[0])).join(''); - expect(writes).toMatch(/TMS_TOKEN/); + expect.assertions(4); + try { + await loadSyncConfig(tmpDir); + } catch (error) { + const err = error as ConfigError; + expect(err).toBeInstanceOf(ConfigError); + expect(err.message).toMatch(/tms\.token/); + expect(err.suggestion).toMatch(/TMS_TOKEN/); + expect(`${err.message} ${err.suggestion ?? ''}`).not.toContain( + 'secret-token' + ); + } }); - it('still emits the warning when stderr is a TTY', async () => { - Object.defineProperty(process.stderr, 'isTTY', { - value: true, - configurable: true, - }); + it('rejects tms.api_key even when TMS_API_KEY is set', async () => { + process.env['TMS_API_KEY'] = 'env-key'; writeConfig(' api_key: secret-key'); - await loadSyncConfig(tmpDir); - - const writes = stderrSpy.mock.calls.map((c) => String(c[0])).join(''); - expect(writes).toMatch(/TMS_API_KEY/); + await expect(loadSyncConfig(tmpDir)).rejects.toThrow(/tms\.api_key/); }); - it('does not emit the warning when tms.api_key is absent', async () => { - Object.defineProperty(process.stderr, 'isTTY', { - value: false, - configurable: true, - }); + it('accepts a tms block that carries no credential', async () => { writeConfig(' enabled: false'); - await loadSyncConfig(tmpDir); - - const writes = stderrSpy.mock.calls.map((c) => String(c[0])).join(''); - expect(writes).not.toMatch(/TMS_API_KEY/); - expect(writes).not.toMatch(/TMS_TOKEN/); - }); - - it('does not emit the warning when TMS_API_KEY env var is already set', async () => { - Object.defineProperty(process.stderr, 'isTTY', { - value: false, - configurable: true, - }); - process.env['TMS_API_KEY'] = 'env-key'; - writeConfig(' api_key: secret-key'); - - await loadSyncConfig(tmpDir); - - const writes = stderrSpy.mock.calls.map((c) => String(c[0])).join(''); - expect(writes).not.toMatch(/TMS_API_KEY/); + await expect(loadSyncConfig(tmpDir)).resolves.toBeDefined(); }); }); diff --git a/tests/unit/sync/tms-client.test.ts b/tests/unit/sync/tms-client.test.ts index 896c509..51f160b 100644 --- a/tests/unit/sync/tms-client.test.ts +++ b/tests/unit/sync/tms-client.test.ts @@ -798,19 +798,18 @@ describe('resolveTmsCredentials', () => { process.env = envSnapshot; }); - it('should prefer env var over config api_key', () => { + it('reads the api key from TMS_API_KEY', () => { process.env['TMS_API_KEY'] = 'env-key'; - const result = resolveTmsCredentials({ api_key: 'config-key' }); - expect(result.apiKey).toBe('env-key'); + expect(resolveTmsCredentials().apiKey).toBe('env-key'); }); - it('should fall back to config api_key when env var not set', () => { - const result = resolveTmsCredentials({ api_key: 'config-key' }); - expect(result.apiKey).toBe('config-key'); + it('reads the token from TMS_TOKEN', () => { + process.env['TMS_TOKEN'] = 'env-token'; + expect(resolveTmsCredentials().token).toBe('env-token'); }); it('should return undefined when neither set', () => { - const result = resolveTmsCredentials({}); + const result = resolveTmsCredentials(); expect(result.apiKey).toBeUndefined(); expect(result.token).toBeUndefined(); }); @@ -818,44 +817,27 @@ describe('resolveTmsCredentials', () => { describe('credential provenance', () => { it('reports env when TMS_API_KEY supplied the key', () => { process.env['TMS_API_KEY'] = 'env-key'; - expect(resolveTmsCredentials({ api_key: 'config-key' }).source).toBe( - 'env' - ); + expect(resolveTmsCredentials().source).toBe('env'); }); it('reports env when TMS_TOKEN supplied the only credential', () => { process.env['TMS_TOKEN'] = 'env-token'; - expect(resolveTmsCredentials({}).source).toBe('env'); - }); - - it('reports config when the credential was inlined in the repo YAML', () => { - expect(resolveTmsCredentials({ api_key: 'config-key' }).source).toBe( - 'config' - ); - }); - - it('reports config for an inlined token', () => { - expect(resolveTmsCredentials({ token: 'config-token' }).source).toBe( - 'config' - ); + expect(resolveTmsCredentials().source).toBe('env'); }); it('reports none when there is no credential', () => { - expect(resolveTmsCredentials({}).source).toBe('none'); + expect(resolveTmsCredentials().source).toBe('none'); }); - it('reports config when the api_key that will actually be sent came from config', () => { - // getAuthHeader prefers apiKey, so an env token is never attached here. + it('reports env when an empty TMS_API_KEY falls through to an env token', () => { + process.env['TMS_API_KEY'] = ''; process.env['TMS_TOKEN'] = 'env-token'; - expect(resolveTmsCredentials({ api_key: 'config-key' }).source).toBe( - 'config' - ); + expect(resolveTmsCredentials().source).toBe('env'); }); - it('reports env when an empty TMS_API_KEY falls through to an env token', () => { + it('reports none for an empty TMS_API_KEY with no token', () => { process.env['TMS_API_KEY'] = ''; - process.env['TMS_TOKEN'] = 'env-token'; - expect(resolveTmsCredentials({}).source).toBe('env'); + expect(resolveTmsCredentials().source).toBe('none'); }); }); }); @@ -911,17 +893,28 @@ describe('createTmsClient destination trust', () => { expect(mockFetch).toHaveBeenCalled(); }); - it('builds a client without gating when the credential is inlined in the repo YAML', async () => { + it('builds a client without gating when there is no credential to leak', async () => { const promptForApproval = jest.fn(async () => true); - const client = await createTmsClient( - { ...baseConfig, api_key: 'config-key' }, - { promptForApproval, canPrompt: () => true } - ); + const client = await createTmsClient(baseConfig, { + promptForApproval, + canPrompt: () => true, + }); mockFetch.mockResolvedValue({ ok: true, json: async () => ({}) }); await client.pushKey('k', 'de', 'v'); expect(promptForApproval).not.toHaveBeenCalled(); }); + it('gates every credential, since all of them now come from the environment', async () => { + process.env['TMS_API_KEY'] = 'env-key'; + const promptForApproval = jest.fn(async () => true); + await createTmsClient(baseConfig, { + readAllowedServers: () => [], + canPrompt: () => true, + promptForApproval, + }); + expect(promptForApproval).toHaveBeenCalled(); + }); + it('records the approval when the user accepts at the prompt', async () => { process.env['TMS_API_KEY'] = 'env-key'; const approveServer = jest.fn(); @@ -961,12 +954,9 @@ describe('createTmsClient', () => { project_id: 'proj-1', }; - it('should read TMS_API_KEY env var when building the client (in preference to config.api_key)', async () => { + it('should read TMS_API_KEY env var when building the client', async () => { process.env['TMS_API_KEY'] = 'env-key'; - const client = await createTmsClient( - { ...baseConfig, api_key: 'config-key' }, - approved - ); + const client = await createTmsClient(baseConfig, approved); mockFetch.mockResolvedValue({ ok: true, json: async () => ({}) }); await client.pushKey('k', 'de', 'v'); @@ -990,11 +980,8 @@ describe('createTmsClient', () => { expect(headers?.['Authorization']).toBe('Bearer env-token'); }); - it('should fall back to config credentials when no env vars set', async () => { - const client = await createTmsClient( - { ...baseConfig, api_key: 'config-key' }, - approved - ); + it('attaches no Authorization header when no env credential is set', async () => { + const client = await createTmsClient(baseConfig, approved); mockFetch.mockResolvedValue({ ok: true, json: async () => ({}) }); await client.pushKey('k', 'de', 'v'); @@ -1002,7 +989,7 @@ describe('createTmsClient', () => { string, string >; - expect(headers?.['Authorization']).toBe('ApiKey config-key'); + expect(headers?.['Authorization']).toBeUndefined(); }); it('should pass server URL and project_id to the constructed client', async () => { diff --git a/tests/unit/sync/tms-server-trust.test.ts b/tests/unit/sync/tms-server-trust.test.ts index 320ba4a..937fa7c 100644 --- a/tests/unit/sync/tms-server-trust.test.ts +++ b/tests/unit/sync/tms-server-trust.test.ts @@ -31,15 +31,6 @@ function harness(overrides: Partial = {}): Harness { describe('ensureTmsServerApproved', () => { describe('credential provenance', () => { - it('does not gate a credential inlined in the repo YAML (nothing of the user’s leaks)', async () => { - const deps = harness(); - await expect( - ensureTmsServerApproved('https://tms.evil.test', 'config', deps) - ).resolves.toBeUndefined(); - expect(deps.promptForApproval).not.toHaveBeenCalled(); - expect(deps.readAllowedServers).not.toHaveBeenCalled(); - }); - it('does not gate when there is no credential at all', async () => { const deps = harness(); await expect(