From b61e809e1f49ca088b2298d3629e8a13d6ae0068 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Wed, 29 Jul 2026 23:41:45 -0400 Subject: [PATCH 01/13] fix(deps): declare @inquirer/prompts and diff as runtime dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are loaded at runtime from src/ but were absent from dependencies, so they resolved only by accident of the dev tree or npm's flat hoisting. diff is a static import in cli/commands/write.ts and was declared only in devDependencies, which consumers never install: `deepl write` failed with "Cannot find package 'diff'" on every install of the packed tarball, before any output. The --help sweep did not surface it because write.js loads lazily via service-factory's dynamic import. @inquirer/prompts is imported by init, write --interactive and sync init while only the unused `inquirer` was declared. Under a strict layout it raises ERR_MODULE_NOT_FOUND; under npm it binds to whatever major a consumer happens to hoist, in the prompt that reads the user's API key. Dependency ranges are immutable per published version, so neither is repairable after 2.0.0 ships. scripts/check-dependencies.mjs fails the build on either class: it compares every import specifier in src/ against dependencies alone, so a runtime import sitting in devDependencies is an error. Package names are also matched as quoted strings so indirect loads such as requireModule('php-parser') count as references. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- package-lock.json | 39 +------------- package.json | 5 +- scripts/check-dependencies.mjs | 94 ++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 39 deletions(-) create mode 100644 scripts/check-dependencies.mjs diff --git a/package-lock.json b/package-lock.json index c61f6d1..cab72a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,15 @@ "version": "1.2.0", "license": "MIT", "dependencies": { + "@inquirer/prompts": "^8.5.2", "axios": "^1.7.9", "chalk": "^6.0.0", "chokidar": "^5.0.0", "cli-table3": "^0.6.5", "commander": "^15.0.0", + "diff": "^9.0.0", "fast-glob": "^3.3.3", "form-data": "^4.0.4", - "inquirer": "^14.0.2", "minimatch": "^10.2.6", "ora": "^9.4.1", "p-limit": "^7.3.1", @@ -36,7 +37,6 @@ "@types/node": "^26.1.2", "@types/ws": "^8.18.1", "ajv": "^8.20.0", - "diff": "^9.0.0", "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-jest": "^29.16.0", @@ -4536,7 +4536,6 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -5709,31 +5708,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/inquirer": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-14.0.2.tgz", - "integrity": "sha512-VsSx1JneSNp3ld1veMTLe+UDcUD8Tw2/jjOthhkX3/IX2q+xHhVELifeb/hsb1fBw31pabEPNUf/xUOyb+KZjA==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/core": "^11.2.1", - "@inquirer/prompts": "^8.5.2", - "@inquirer/type": "^4.0.7", - "mute-stream": "^3.0.0", - "run-async": "^4.0.6" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -8139,15 +8113,6 @@ "node": ">=0.10.0" } }, - "node_modules/run-async": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-4.0.6.tgz", - "integrity": "sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", diff --git a/package.json b/package.json index 7695526..40df8b9 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "lint": "eslint 'src/**/*.ts' 'tests/**/*.ts'", "lint:fix": "eslint 'src/**/*.ts' 'tests/**/*.ts' --fix", "type-check": "tsc --noEmit", + "check-deps": "node scripts/check-dependencies.mjs", "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"", "prepublishOnly": "npm run build" @@ -62,14 +63,15 @@ "npm": ">=9.0.0" }, "dependencies": { + "@inquirer/prompts": "^8.5.2", "axios": "^1.7.9", "chalk": "^6.0.0", "chokidar": "^5.0.0", "cli-table3": "^0.6.5", "commander": "^15.0.0", + "diff": "^9.0.0", "fast-glob": "^3.3.3", "form-data": "^4.0.4", - "inquirer": "^14.0.2", "minimatch": "^10.2.6", "ora": "^9.4.1", "p-limit": "^7.3.1", @@ -86,7 +88,6 @@ "@types/node": "^26.1.2", "@types/ws": "^8.18.1", "ajv": "^8.20.0", - "diff": "^9.0.0", "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-jest": "^29.16.0", diff --git a/scripts/check-dependencies.mjs b/scripts/check-dependencies.mjs new file mode 100644 index 0000000..755ec3a --- /dev/null +++ b/scripts/check-dependencies.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +/** + * Verifies that every external package referenced by src/ is declared in + * package.json dependencies, and reports declared packages that src/ never + * references. + * + * Undeclared imports resolve only by accident of npm's flat hoisting: a strict + * layout (pnpm, --install-strategy=nested) fails outright, and a consumer with + * its own copy of the package silently binds our import to their version. + * + * Package names are matched as quoted strings anywhere in the source so that + * indirect loads (requireModule('php-parser')) count as references. + */ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { builtinModules } from 'node:module'; +import * as path from 'node:path'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const SRC = path.join(ROOT, 'src'); + +const BUILTINS = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)]); + +function sourceFiles(dir) { + return readdirSync(dir).flatMap((entry) => { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) { + return sourceFiles(full); + } + return full.endsWith('.ts') ? [full] : []; + }); +} + +function packageName(specifier) { + const segments = specifier.split('/'); + return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]; +} + +/** + * `from` is anchored to a preceding delimiter so that the word inside a string + * literal — `new Set(['from', 'format'])` — is not read as an import. + */ +const SPECIFIER_PATTERNS = [ + /(?:^|[\s;})])from[ \t]+['"]([^'"]+)['"]/gm, + /\bimport[ \t]*\([ \t]*['"]([^'"]+)['"]/g, + /\brequire[ \t]*\([ \t]*['"]([^'"]+)['"]/g, +]; + +const manifest = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf-8')); +const declared = new Set(Object.keys(manifest.dependencies ?? {})); + +const undeclared = []; +const referenced = new Set(); + +for (const file of sourceFiles(SRC)) { + const contents = readFileSync(file, 'utf-8'); + const relative = path.relative(ROOT, file); + + for (const pattern of SPECIFIER_PATTERNS) { + for (const match of contents.matchAll(pattern)) { + const specifier = match[1]; + if (specifier.startsWith('.') || BUILTINS.has(specifier)) { + continue; + } + const name = packageName(specifier); + referenced.add(name); + if (!declared.has(name)) { + const line = contents.slice(0, match.index).split('\n').length; + undeclared.push({ location: `${relative}:${line}`, name }); + } + } + } + + for (const name of declared) { + if (contents.includes(`'${name}'`) || contents.includes(`"${name}"`)) { + referenced.add(name); + } + } +} + +const unused = [...declared].filter((name) => !referenced.has(name)).sort(); + +for (const { location, name } of undeclared) { + console.error(`ERROR ${location.padEnd(52)} undeclared: ${name}`); +} +for (const name of unused) { + console.error(`ERROR package.json${' '.repeat(40)} declared but unreferenced: ${name}`); +} + +if (undeclared.length > 0 || unused.length > 0) { + console.error(`\n${undeclared.length} undeclared, ${unused.length} unreferenced`); + process.exit(1); +} + +console.log(`${referenced.size} referenced packages, all declared`); From c5e1f66ff2bc4fd5b48cd8398d103ab0c78ce0a3 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Wed, 29 Jul 2026 23:43:34 -0400 Subject: [PATCH 02/13] ci(release): refuse to publish when the tag and package.json disagree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm rejects a duplicate version, but @deepl/cli has no published version for a mistake to collide with: tagging v2.0.0 before the version bump would publish the stale version, title a Release v2.0.0 whose notes silently fall back to generated ones, and sign an immutable provenance attestation binding the wrong version to the tag. Every job would report success. Also in the publish job, which is the only one holding NPM_TOKEN: actions are pinned to commit SHAs so a moved tag cannot run unreviewed code with publish credentials. Release creation is skipped when the release already exists, so re-running after a publish failure no longer fails on work that succeeded. The VERSION file is removed rather than guarded against drift: nothing reads it, package.json is the only source src/version.ts consults, and npm version keeps package.json and the lockfile in step. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 29 ++++++++++++++++++++++++++--- CLAUDE.md | 4 ++-- VERSION | 1 - tests/unit/package-manifest.test.ts | 22 ++++++++++++++++++++++ 5 files changed, 51 insertions(+), 6 deletions(-) delete mode 100644 VERSION diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37d3abe..29da180 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,6 @@ jobs: - run: npm ci - run: npm run lint - run: npm run type-check + - run: npm run check-deps - run: npm run build - run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7162ff..009a7b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,15 +20,31 @@ jobs: id-token: write steps: - - uses: actions/checkout@v7 - - - uses: actions/setup-node@v7 + # Actions in this job are pinned to commit SHAs because it is the only + # job holding NPM_TOKEN; a moved tag here would run unreviewed code + # with publish credentials. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: '24' registry-url: https://registry.npmjs.org cache: npm - run: npm ci + + # npm rejects a duplicate version, but this package's first publish has + # no prior version to collide with: without this check, tagging v2.0.0 + # before the version bump publishes the stale version under a v2.0.0 + # provenance attestation, and npm versions cannot be reused. + - name: Verify tag matches package version + run: | + tag_version="${GITHUB_REF_NAME#v}" + package_version="$(node -p 'require("./package.json").version')" + if [ "$tag_version" != "$package_version" ]; then + echo "::error::tag $GITHUB_REF_NAME does not match package.json version $package_version" + exit 1 + fi + - run: npm run build # First publish authenticates with a granular NPM_TOKEN scoped to # @deepl/cli. Switch to OIDC trusted publishing once the package @@ -61,10 +77,17 @@ jobs: echo "has_notes=true" >> "$GITHUB_OUTPUT" fi + # Skipped when the release already exists so that re-running the + # workflow after a publish failure does not fail on work that + # already succeeded. - name: Create GitHub Release env: GH_TOKEN: ${{ github.token }} run: | + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + echo "release $GITHUB_REF_NAME already exists, leaving it unchanged" + exit 0 + fi if [ "${{ steps.notes.outputs.has_notes }}" = "true" ]; then gh release create "$GITHUB_REF_NAME" --verify-tag \ --title "$GITHUB_REF_NAME" --notes-file release-notes.md diff --git a/CLAUDE.md b/CLAUDE.md index d534c44..d0fbecf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ DeepL CLI is a command-line interface for the DeepL API that integrates translat ### Current Status -- **Version**: see `VERSION` file / `package.json` +- **Version**: see `package.json` (the single source of truth; `src/version.ts` reads it at runtime) - **Tests**: see `npm test` output (target: all green; coverage thresholds enforced by jest config) - **Test mix**: ~70-75% unit, ~25-30% integration/e2e @@ -78,7 +78,7 @@ Use **Semantic Versioning** with **Conventional Commits**: ### When Cutting a Release 1. Move Unreleased items to `## [X.Y.Z] - YYYY-MM-DD` -2. Update `VERSION` and `package.json` version +2. Set the version with `npm version X.Y.Z --no-git-tag-version` (updates `package.json` and the lockfile together; the release workflow refuses to publish if the tag and `package.json` disagree) 3. Create annotated tag: `git tag -a vX.Y.Z -m "Release vX.Y.Z: "` 4. Push: `git push && git push --tags` diff --git a/VERSION b/VERSION deleted file mode 100644 index 26aaba0..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -1.2.0 diff --git a/tests/unit/package-manifest.test.ts b/tests/unit/package-manifest.test.ts index ea158fc..21c4354 100644 --- a/tests/unit/package-manifest.test.ts +++ b/tests/unit/package-manifest.test.ts @@ -11,6 +11,7 @@ import * as path from 'path'; interface PackageManifest { name: string; + version: string; publishConfig?: { access?: string }; repository: { type: string; url: string }; bugs: { url: string }; @@ -19,9 +20,12 @@ interface PackageManifest { clean?: string; build: string; prepublishOnly: string; + 'check-deps'?: string; }; files: string[]; bin: Record; + dependencies: Record; + devDependencies: Record; } describe('package.json manifest', () => { @@ -74,6 +78,24 @@ describe('package.json manifest', () => { }); }); + describe('runtime dependencies', () => { + // Consumers install dependencies only. A package imported by src/ but + // declared under devDependencies resolves in this tree and fails on every + // real install, and dependency ranges cannot be changed after publish. + it.each(['@inquirer/prompts', 'diff'])('should declare %s as a runtime dependency', (name) => { + expect(pkg.dependencies[name]).toBeDefined(); + expect(pkg.devDependencies[name]).toBeUndefined(); + }); + + it('should not declare packages that no source file imports', () => { + expect(pkg.dependencies['inquirer']).toBeUndefined(); + }); + + it('should expose the dependency check as a script so CI can gate on it', () => { + expect(pkg.scripts['check-deps']).toContain('check-dependencies'); + }); + }); + describe('publish identity', () => { it('should be the scoped @deepl/cli package', () => { expect(pkg.name).toBe('@deepl/cli'); From 903acf539818768f69358415614b51292b2a25d3 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Wed, 29 Jul 2026 23:45:49 -0400 Subject: [PATCH 03/13] test(harness): stop the suite inheriting real credentials and config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nock cannot intercept across a process boundary, so suites that spawn the bare `deepl` command reached the live DeepL API with whatever key the developer had exported, and read and wrote the real cache database. Six cached Write responses matching this suite's fixtures were recovered from a developer cache, so this had already happened repeatedly; suite runtime drops from ~48s to ~28s once the calls stop. globalSetup now unsets DEEPL_API_KEY, TMS_API_KEY and TMS_TOKEN and points DEEPL_CONFIG_DIR at a temporary directory before workers fork, which covers every spawner rather than the individual suites that opted out of the isolated runner. Suites that need a key still pass one explicitly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tests/global-setup.ts | 3 ++- tests/hermetic-deepl.ts | 21 +++++++++++++++++++++ tests/unit/hermetic-deepl.test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/tests/global-setup.ts b/tests/global-setup.ts index 9815a0a..d7c985e 100644 --- a/tests/global-setup.ts +++ b/tests/global-setup.ts @@ -1,4 +1,4 @@ -import installHermeticDeepl from './hermetic-deepl'; +import installHermeticDeepl, { isolateCredentialEnvironment } from './hermetic-deepl'; /** * Runs in the main jest process before workers spawn, so the shim's PATH entry @@ -9,4 +9,5 @@ import installHermeticDeepl from './hermetic-deepl'; */ export default function globalSetup(): void { installHermeticDeepl(); + isolateCredentialEnvironment(); } diff --git a/tests/hermetic-deepl.ts b/tests/hermetic-deepl.ts index a54b74e..59802ee 100644 --- a/tests/hermetic-deepl.ts +++ b/tests/hermetic-deepl.ts @@ -25,3 +25,24 @@ export default function installHermeticDeepl(): void { process.env['DEEPL_CLI_TEST_SHIM'] = shimDir; process.env['PATH'] = `${shimDir}${path.delimiter}${process.env['PATH'] ?? ''}`; } + +/** + * Removes real credentials and the real config directory from the environment + * the whole suite inherits. + * + * nock cannot intercept across a process boundary, so any spawned CLI that + * inherits a working DEEPL_API_KEY reaches the live API: doing so spent real + * quota and wrote real responses into the developer's cache. Suites that need + * a key pass one explicitly, which still overrides this. + */ +export function isolateCredentialEnvironment(): void { + delete process.env['DEEPL_API_KEY']; + delete process.env['TMS_API_KEY']; + delete process.env['TMS_TOKEN']; + + if (!process.env['DEEPL_CONFIG_DIR']) { + process.env['DEEPL_CONFIG_DIR'] = fs.mkdtempSync( + path.join(os.tmpdir(), 'deepl-cli-test-config-'), + ); + } +} diff --git a/tests/unit/hermetic-deepl.test.ts b/tests/unit/hermetic-deepl.test.ts index d7002a7..1219dae 100644 --- a/tests/unit/hermetic-deepl.test.ts +++ b/tests/unit/hermetic-deepl.test.ts @@ -6,6 +6,7 @@ import { execSync } from 'child_process'; import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; describe('hermetic deepl shim', () => { @@ -26,4 +27,31 @@ describe('hermetic deepl shim', () => { expect(version).toBe(pkg.version); }); + + describe('credential isolation', () => { + // A spawned CLI cannot be intercepted by nock, so an inherited real key + // reaches the live API. These assert the suite never carries one. + it.each(['DEEPL_API_KEY', 'TMS_API_KEY', 'TMS_TOKEN'])( + 'does not carry %s in the inherited environment', + (name) => { + expect(process.env[name]).toBeUndefined(); + }, + ); + + it('points DEEPL_CONFIG_DIR at a temporary directory, not the real one', () => { + const configDir = process.env['DEEPL_CONFIG_DIR']; + + expect(configDir).toBeDefined(); + expect(configDir!.startsWith(os.tmpdir())).toBe(true); + }); + + it('reports no API key to a bare spawned command', () => { + const output = execSync('deepl auth show 2>&1 || true', { + encoding: 'utf-8', + shell: '/bin/sh', + }); + + expect(output).toMatch(/no api key|not set|not configured/i); + }); + }); }); From 9f200f1fc8aaf799f611d2835cd2cc0462d81125 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Wed, 29 Jul 2026 23:49:00 -0400 Subject: [PATCH 04/13] fix(cli): guard init on a non-TTY, correct the bug URL, hide internal commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deepl init only checked --no-input, so with stdin at EOF it began prompting and died with exit 1 plus a Node unsettled-top-level-await warning, where the documented code is 6. The sibling guard in register-write.ts already pairs isNoInput() with process.stdin.isTTY. The existing test covered `--no-input init`, the variant that worked, so the guard looked tested. The unexpected-API-response error told users to report bugs at github.com/user/deepl-cli, a live third-party account that could create that repository and receive reports from a DeepL-branded binary; the string is compiled into the tarball, so it would freeze into 2.0.0. Shell completions and the did-you-mean suggester both enumerated commands unfiltered, publishing the hidden _describe command to end users. Both now use commander's visibleCommands. The suggester also considers aliases and prefers a prefix match, so `deepl tr` resolves to translate rather than tm. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/release.yml | 8 +++++++ src/api/translation-client.ts | 4 ++-- src/cli/commands/completion.ts | 32 +++++++++++++++++---------- src/cli/commands/register-init.ts | 2 +- src/cli/index.ts | 17 ++++++++++---- tests/e2e/cli-no-input.e2e.test.ts | 19 ++++++++++++++++ tests/unit/cli-did-you-mean.test.ts | 12 ++++++++++ tests/unit/completion-command.test.ts | 29 ++++++++++++++++++++++++ 8 files changed, 104 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 009a7b7..6d0e03c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,15 @@ jobs: exit 1 fi + # A tag can point at any commit, including one CI never ran, so the + # publish job re-verifies rather than trusting the branch. No DeepL API + # key is available to this job: the suite must stay hermetic. + - run: npm run lint + - run: npm run type-check + - run: npm run check-deps - run: npm run build + - run: npm test + # First publish authenticates with a granular NPM_TOKEN scoped to # @deepl/cli. Switch to OIDC trusted publishing once the package # exists on npmjs.com, then revoke the token (tracked in beads). diff --git a/src/api/translation-client.ts b/src/api/translation-client.ts index 4bafaa8..21a6260 100644 --- a/src/api/translation-client.ts +++ b/src/api/translation-client.ts @@ -155,11 +155,11 @@ export class TranslationClient extends HttpClient { ); if (!response.translations) { - throw new NetworkError('Unexpected API response. Please retry your translation. If the issue persists, report it at https://github.com/user/deepl-cli/issues'); + throw new NetworkError('Unexpected API response. Please retry your translation. If the issue persists, report it at https://github.com/DeepL/deepl-cli/issues'); } if (response.translations.length !== texts.length) { - throw new NetworkError('Unexpected API response. Please retry your translation. If the issue persists, report it at https://github.com/user/deepl-cli/issues'); + throw new NetworkError('Unexpected API response. Please retry your translation. If the issue persists, report it at https://github.com/DeepL/deepl-cli/issues'); } return response.translations.map((translation) => ({ diff --git a/src/cli/commands/completion.ts b/src/cli/commands/completion.ts index 15a27b8..521e6bc 100644 --- a/src/cli/commands/completion.ts +++ b/src/cli/commands/completion.ts @@ -20,13 +20,18 @@ export class CompletionCommand { } } + /** Hidden commands are internal surface and must not be offered to users. */ + private visibleCommands(parent: Command): Command[] { + return this.program.createHelp().visibleCommands(parent); + } + private getCommandTree(): Map { const tree = new Map(); const topLevel: string[] = []; - for (const cmd of this.program.commands) { + for (const cmd of this.visibleCommands(this.program)) { topLevel.push(cmd.name(), ...cmd.aliases()); - const subcommands = cmd.commands.map((sub) => sub.name()); + const subcommands = this.visibleCommands(cmd).map((sub) => sub.name()); if (subcommands.length > 0) { tree.set(cmd.name(), subcommands); } @@ -49,9 +54,14 @@ export class CompletionCommand { } private getGlobalOptions(): string[] { - return this.program.options - .map((opt) => opt.long) - .filter((o): o is string => !!o); + return [ + ...new Set( + this.program.options + .map((opt) => opt.long) + .filter((o): o is string => !!o) + .concat('--help', '--version'), + ), + ]; } private generateBash(): string { @@ -69,7 +79,7 @@ export class CompletionCommand { subcommandCases.push(` ${parent})\n COMPREPLY=($(compgen -W "${words}" -- "\${cur}"))\n return 0\n ;;`); } - const topLevelWords = [...topLevel, ...globalOpts, '--help', '--version'].join(' '); + const topLevelWords = [...topLevel, ...globalOpts].join(' '); return `# bash completion for deepl -*- shell-script -*- # @@ -122,9 +132,9 @@ complete -F _deepl_completions deepl const cmdOpts = this.getCommandOptions(parent); const descriptions: string[] = []; - const parentCmd = this.program.commands.find((c) => c.name() === parent); + const parentCmd = this.findCommand(parent); if (parentCmd) { - for (const sub of parentCmd.commands) { + for (const sub of this.visibleCommands(parentCmd)) { const desc = sub.description().replace(/'/g, "'\\''"); descriptions.push(`'${sub.name()}:${desc}'`); } @@ -157,8 +167,6 @@ complete -F _deepl_completions deepl const desc = optObj ? optObj.description.replace(/'/g, "'\\''") : ''; topLevelDescriptions.push(`'${opt}:${desc}'`); } - topLevelDescriptions.push("'--help:Show help'"); - topLevelDescriptions.push("'--version:Show version'"); const fpathRef = '${fpath[1]}'; @@ -247,7 +255,7 @@ _deepl "$@" if (parent === '__root__') { continue; } - const parentCmd = this.program.commands.find((c) => c.name() === parent); + const parentCmd = this.findCommand(parent); const seenCondition = `__fish_seen_subcommand_from ${parent}`; const notSeenSub = subs.length > 0 ? `; and not __fish_seen_subcommand_from ${subs.join(' ')}` @@ -255,7 +263,7 @@ _deepl "$@" lines.push(`# ${parent} subcommands`); if (parentCmd) { - for (const sub of parentCmd.commands) { + for (const sub of this.visibleCommands(parentCmd)) { const desc = sub.description().replace(/'/g, "\\'"); lines.push(`complete -c deepl -n '${seenCondition}${notSeenSub}' -a '${sub.name()}' -d '${desc}'`); } diff --git a/src/cli/commands/register-init.ts b/src/cli/commands/register-init.ts index cbd8ae7..3651648 100644 --- a/src/cli/commands/register-init.ts +++ b/src/cli/commands/register-init.ts @@ -23,7 +23,7 @@ Examples: `) .action(async () => { try { - if (isNoInput()) { + if (isNoInput() || !process.stdin.isTTY) { throw new ValidationError('init is not supported in non-interactive mode. Use deepl auth set-key to configure authentication.'); } const { InitCommand } = await import('./init.js'); diff --git a/src/cli/index.ts b/src/cli/index.ts index 24ab8f8..cf3f89d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -380,14 +380,23 @@ program.on('command:*', (operands: string[]) => { return; } - const commandNames = program.commands.map((cmd) => cmd.name()); + // Hidden commands are internal surface, so they are never suggested. An + // alias match resolves to the command it aliases, which is the name the + // user is looking for. + const candidates = program + .createHelp() + .visibleCommands(program) + .flatMap((cmd) => [cmd.name(), ...cmd.aliases()].map((name) => ({ name, target: cmd.name() }))); + let bestMatch = ''; let bestDistance = Infinity; - for (const name of commandNames) { - const d = levenshtein(unknown, name); + for (const { name, target } of candidates) { + // A typed prefix is a stronger signal than edit distance: "tr" is 2 edits + // from both "tm" and "translate", but only one of them was being typed. + const d = name.startsWith(unknown) ? 0 : levenshtein(unknown, name); if (d < bestDistance) { bestDistance = d; - bestMatch = name; + bestMatch = target; } } diff --git a/tests/e2e/cli-no-input.e2e.test.ts b/tests/e2e/cli-no-input.e2e.test.ts index 6dcbfee..1b019fd 100644 --- a/tests/e2e/cli-no-input.e2e.test.ts +++ b/tests/e2e/cli-no-input.e2e.test.ts @@ -35,6 +35,25 @@ describe('--no-input E2E', () => { expect(error.stderr.toString()).toContain('not supported in non-interactive mode'); } }); + + // Without --no-input the wizard used to start prompting, then die at EOF + // with exit 1 and a Node unsettled-top-level-await warning. Docker without + // -it, CI, and piped invocations all take this path. + it('should exit with code 6 when stdin is not a terminal', () => { + expect.assertions(3); + try { + execSync('deepl init < /dev/null', { + encoding: 'utf-8', + stdio: 'pipe', + shell: '/bin/sh', + }); + } catch (error: any) { + const stderr = error.stderr.toString(); + expect(error.status).toBe(6); + expect(stderr).toContain('not supported in non-interactive mode'); + expect(stderr).not.toContain('unsettled top-level await'); + } + }); }); describe('write --interactive', () => { diff --git a/tests/unit/cli-did-you-mean.test.ts b/tests/unit/cli-did-you-mean.test.ts index c4a733d..3c719a5 100644 --- a/tests/unit/cli-did-you-mean.test.ts +++ b/tests/unit/cli-did-you-mean.test.ts @@ -63,4 +63,16 @@ describe('CLI did-you-mean suggestions', () => { const combined = result.stdout + result.stderr; expect(combined).toContain('deepl --help'); }); + + it('should suggest translate, not an unrelated command, for a prefix of an alias', () => { + const result = runCLI('tr'); + const combined = result.stdout + result.stderr; + expect(combined).toContain('Did you mean: deepl translate?'); + }); + + it.each(['descibe', 'describe'])('should never suggest the hidden _describe command (%s)', (typo) => { + const result = runCLI(typo); + const combined = result.stdout + result.stderr; + expect(combined).not.toContain('_describe'); + }); }); diff --git a/tests/unit/completion-command.test.ts b/tests/unit/completion-command.test.ts index be78cca..c743932 100644 --- a/tests/unit/completion-command.test.ts +++ b/tests/unit/completion-command.test.ts @@ -277,6 +277,35 @@ describe('CompletionCommand', () => { }); }); + describe('hidden commands', () => { + // Hidden commands are internal surface; offering them in tab-completion + // publishes them to end users. + it.each(['bash', 'zsh', 'fish'] as const)('omits hidden commands from %s completions', (shell) => { + const program = new Command(); + program.name('deepl'); + program.command('translate').description('Translate text'); + program.command('_internal', { hidden: true }).description('Internal'); + + const script = new CompletionCommand(program).generate(shell); + + expect(script).toContain('translate'); + expect(script).not.toContain('_internal'); + }); + + it('omits hidden subcommands', () => { + const program = new Command(); + program.name('deepl'); + const sync = program.command('sync').description('Sync'); + sync.command('status').description('Status'); + sync.command('secret-report', { hidden: true }).description('Internal'); + + const script = new CompletionCommand(program).generate('fish'); + + expect(script).toContain('status'); + expect(script).not.toContain('secret-report'); + }); + }); + describe('command aliases', () => { it('bash: offers aliases as top-level completions', () => { const script = completionCommand.generate('bash'); From 1766de2d93c687695d381e009ff2046125fb4d28 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Wed, 29 Jul 2026 23:57:26 -0400 Subject: [PATCH 05/13] fix: normalize write language casing, pin the prototype guard, harden config writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit write --lang/--to compared codes against a mixed-case list with an exact match, so it rejected the lowercase codes `deepl languages` prints and `translate --to` accepts — the CLI's own discovery output was unusable with the command documented as consistent with translate. Codes are now accepted in any casing and normalized to the API's form. The prototype-pollution guard in formats/json.ts was load-bearing but unpinned: replacing Object.defineProperty with plain assignment left the entire suite green. The uncovered path is inserting a key the target lacks, where `obj['__proto__'] = v` retargets the fresh object's prototype rather than Object.prototype, so the translation is dropped while the negative pollution assertion still holds. The new positive-data cases fail on that mutation. ConfigService.save() wrote through a fixed config.json.tmp. A symlink planted there redirected the plaintext API key to a path of the planter's choosing and left config.json as that symlink for every later write. The temp file now has an unpredictable name and is created with the exclusive flag, so anything already at the path fails the write instead of being followed. auth set-key gains --no-verify, and an unreachable API now names both offline paths: previously validation ran before persisting, so on a network without proxy configuration every documented way to store a key was closed. The missing-key error is raised as AuthError instead of a bare warn, so its remediation survives --quiet, which suppresses warnings entirely; API.md said otherwise and is corrected. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/API.md | 4 +- src/cli/commands/auth.ts | 17 ++++++- src/cli/commands/register-auth.ts | 13 ++++-- src/cli/commands/register-write.ts | 16 ++++++- src/cli/index.ts | 22 +++------ src/storage/config.ts | 14 ++++-- tests/hermetic-deepl.ts | 8 ++-- tests/unit/auth-command.test.ts | 40 +++++++++++++++++ tests/unit/config-service.test.ts | 49 +++++++++++++++++++++ tests/unit/prototype-key-safety.test.ts | 32 ++++++++++++++ tests/unit/register-commands-group1.test.ts | 2 +- tests/unit/register-init.test.ts | 30 +++++++++++++ tests/unit/register-write.test.ts | 32 ++++++++++++++ 13 files changed, 245 insertions(+), 34 deletions(-) diff --git a/docs/API.md b/docs/API.md index 8db5692..eb099d4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -97,8 +97,8 @@ deepl cache enable **Quiet Mode Behavior:** -- ✅ **Always shown**: Errors, warnings about critical issues, essential output (translation results, JSON data, command output) -- ❌ **Suppressed**: Informational messages, success confirmations, progress spinners, status updates +- ✅ **Always shown**: Errors and their `Suggestion:` remediation lines, essential output (translation results, JSON data, command output) +- ❌ **Suppressed**: Warnings, informational messages, success confirmations, progress spinners, status updates - 🎯 **Use cases**: CI/CD pipelines, scripting, parsing output, quiet automation **Non-Interactive Mode (`--no-input`):** diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts index 2d91d3f..3ab3e65 100644 --- a/src/cli/commands/auth.ts +++ b/src/cli/commands/auth.ts @@ -6,7 +6,7 @@ import { ConfigService } from '../../storage/config.js'; import { DeepLClient } from '../../api/deepl-client.js'; import type { DeepLClientOptions } from '../../api/http-client.js'; -import { ValidationError, AuthError } from '../../utils/errors.js'; +import { ValidationError, AuthError, NetworkError } from '../../utils/errors.js'; import { resolveEndpoint } from '../../utils/resolve-endpoint.js'; export class AuthCommand { @@ -21,12 +21,17 @@ export class AuthCommand { /** * Set API key and validate it */ - async setKey(apiKey: string): Promise { + async setKey(apiKey: string, options: { verify?: boolean } = {}): Promise { // Validate input if (!apiKey || apiKey.trim() === '') { throw new ValidationError('API key cannot be empty'); } + if (options.verify === false) { + this.config.set('auth.apiKey', apiKey); + return; + } + // Validate with DeepL API by making a test request // Note: No format validation - let the API determine if the key is valid // This supports production keys (:fx suffix), free keys, and test keys @@ -45,6 +50,14 @@ export class AuthCommand { 'Invalid API key: Authentication failed with DeepL API' ); } + // The key is discarded when validation cannot reach the API at all, so + // name the paths that do not require network access. + if (error instanceof NetworkError) { + throw new NetworkError( + `Could not reach the DeepL API to validate the key: ${error.message}`, + 'Store the key without validating with --no-verify, or set DEEPL_API_KEY in your environment instead.', + ); + } throw error; } throw new AuthError('Failed to validate API key'); diff --git a/src/cli/commands/register-auth.ts b/src/cli/commands/register-auth.ts index 8c93ed3..38529d8 100644 --- a/src/cli/commands/register-auth.ts +++ b/src/cli/commands/register-auth.ts @@ -34,7 +34,8 @@ Command arguments are visible to other users via process listings. .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') - .action(async (apiKey: string | undefined, opts: { fromStdin?: boolean }) => { + .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 }) => { try { let key = apiKey; if (apiKey && !opts.fromStdin) { @@ -60,8 +61,14 @@ Command arguments are visible to other users via process listings. } const { AuthCommand } = await import('./auth.js'); const authCommand = new AuthCommand(getConfigService(), getHttpOptions?.()); - await authCommand.setKey(key); - Logger.success(chalk.green('\u2713 API key saved and validated successfully')); + await authCommand.setKey(key, { verify: opts.verify }); + Logger.success( + chalk.green( + opts.verify === false + ? '\u2713 API key saved without validation' + : '\u2713 API key saved and validated successfully', + ), + ); } catch (error) { handleError(error); diff --git a/src/cli/commands/register-write.ts b/src/cli/commands/register-write.ts index 0507322..de61530 100644 --- a/src/cli/commands/register-write.ts +++ b/src/cli/commands/register-write.ts @@ -10,6 +10,14 @@ import { ValidationError } from '../../utils/errors.js'; import { createWriteCommand, type ServiceDeps } from './service-factory.js'; const WRITE_LANGUAGES = ['de', 'en', 'en-GB', 'en-US', 'es', 'fr', 'it', 'ja', 'ko', 'pt', 'pt-BR', 'pt-PT', 'zh', 'zh-Hans'] as const; +/** + * Codes are accepted in any casing and normalized to the API's form, matching + * `translate --to` and the lowercase codes `deepl languages` prints. + */ +const WRITE_LANGUAGE_BY_LOWERCASE = new Map( + WRITE_LANGUAGES.map((language) => [language.toLowerCase(), language]), +); + const WRITE_STYLES = ['default', 'simple', 'business', 'academic', 'casual', 'prefer_simple', 'prefer_business', 'prefer_academic', 'prefer_casual'] as const; const WRITE_TONES = ['default', 'enthusiastic', 'friendly', 'confident', 'diplomatic', 'prefer_enthusiastic', 'prefer_friendly', 'prefer_confident', 'prefer_diplomatic'] as const; @@ -92,8 +100,12 @@ Examples: text = stdinText; } - if (options.lang && !(WRITE_LANGUAGES as readonly string[]).includes(options.lang)) { - throw new ValidationError(`Invalid language code: ${options.lang}. Valid options: ${WRITE_LANGUAGES.join(', ')}`); + if (options.lang) { + const canonical = WRITE_LANGUAGE_BY_LOWERCASE.get(options.lang.toLowerCase()); + if (!canonical) { + throw new ValidationError(`Invalid language code: ${options.lang}. Valid options: ${WRITE_LANGUAGES.join(', ')}`); + } + options.lang = canonical; } if (options.style && !(WRITE_STYLES as readonly string[]).includes(options.style)) { diff --git a/src/cli/index.ts b/src/cli/index.ts index cf3f89d..543e7b0 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -15,7 +15,7 @@ import { createCacheServiceGetter, resolveCacheOptions } from './cache-loader.js import { resolvePaths } from '../utils/paths.js'; import type { DeepLClient } from '../api/deepl-client.js'; import { Logger } from '../utils/logger.js'; -import { DeepLCLIError } from '../utils/errors.js'; +import { AuthError, DeepLCLIError } from '../utils/errors.js'; import { ExitCode, getExitCodeFromError } from '../utils/exit-codes.js'; import { isSymlink } from '../utils/safe-read-file.js'; import { setNoInput } from '../utils/confirm.js'; @@ -116,13 +116,9 @@ async function createDeepLClient( const key = apiKey ?? envKey; if (!key) { - Logger.error(chalk.red('Error: API key not set')); - Logger.warn( - chalk.yellow( - 'Run: deepl init (setup wizard) or deepl auth set-key ' - ) - ); - process.exit(ExitCode.AuthError); + // Routed through handleError so the remediation survives --quiet, which + // suppresses Logger.warn entirely. + handleError(new AuthError('API key not set')); } const configBaseUrl = configService.getValue('api.baseUrl'); @@ -261,13 +257,9 @@ function getApiKeyAndOptions(): { const key = apiKey ?? envKey; if (!key) { - Logger.error(chalk.red('Error: API key not set')); - Logger.warn( - chalk.yellow( - 'Run: deepl init (setup wizard) or deepl auth set-key ' - ) - ); - process.exit(ExitCode.AuthError); + // Routed through handleError so the remediation survives --quiet, which + // suppresses Logger.warn entirely. + handleError(new AuthError('API key not set')); } const configBaseUrl = configService.getValue('api.baseUrl'); diff --git a/src/storage/config.ts b/src/storage/config.ts index 5ca5390..5118ae8 100644 --- a/src/storage/config.ts +++ b/src/storage/config.ts @@ -3,6 +3,7 @@ * Handles loading, saving, and accessing configuration */ +import { randomBytes } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import { DeepLConfig, Formality, OutputFormat } from '../types/index.js'; @@ -251,21 +252,26 @@ export class ConfigService { * Save configuration to disk */ private save(): void { + // The temp name is unpredictable and created exclusively: at a fixed path a + // pre-planted symlink would redirect this file — which holds the API key in + // plaintext — to a path of the planter's choosing, and the rename would + // then leave config.json as that symlink for every later write. `wx` fails + // rather than following anything already there. + const tmpPath = `${this.configPath}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`; try { const dir = path.dirname(this.configPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } - const tmpPath = this.configPath + '.tmp'; fs.writeFileSync( tmpPath, JSON.stringify(this.config, null, 2), - { encoding: 'utf-8', mode: 0o600 } + { encoding: 'utf-8', mode: 0o600, flag: 'wx' } ); + // The mode above is masked by the umask at creation; chmod is not. + fs.chmodSync(tmpPath, 0o600); fs.renameSync(tmpPath, this.configPath); } catch (error) { - // Clean up temp file on failure - const tmpPath = this.configPath + '.tmp'; try { fs.unlinkSync(tmpPath); } catch { /* ignore cleanup errors */ } throw new ConfigError(`Failed to save config: ${errorMessage(error)}`); } diff --git a/tests/hermetic-deepl.ts b/tests/hermetic-deepl.ts index 59802ee..ee7a18e 100644 --- a/tests/hermetic-deepl.ts +++ b/tests/hermetic-deepl.ts @@ -40,9 +40,7 @@ export function isolateCredentialEnvironment(): void { delete process.env['TMS_API_KEY']; delete process.env['TMS_TOKEN']; - if (!process.env['DEEPL_CONFIG_DIR']) { - process.env['DEEPL_CONFIG_DIR'] = fs.mkdtempSync( - path.join(os.tmpdir(), 'deepl-cli-test-config-'), - ); - } + process.env['DEEPL_CONFIG_DIR'] ??= fs.mkdtempSync( + path.join(os.tmpdir(), 'deepl-cli-test-config-'), + ); } diff --git a/tests/unit/auth-command.test.ts b/tests/unit/auth-command.test.ts index 268ff4e..684f4e6 100644 --- a/tests/unit/auth-command.test.ts +++ b/tests/unit/auth-command.test.ts @@ -9,6 +9,7 @@ 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'; // Mock chalk (ESM-only) jest.mock('chalk', () => { @@ -126,6 +127,45 @@ describe('AuthCommand', () => { await expect(authCommand.setKey('test-key')).rejects.toThrow('Network timeout'); }); + + describe('offline storage', () => { + // Validation requires network access, so on a locked-down network every + // documented way to store a key was closed. + it('should store the key without contacting the API when verify is false', async () => { + const mockGetUsage = jest.fn(); + (DeepLClient as jest.MockedClass).mockImplementation(() => ({ + getUsage: mockGetUsage, + } as any)); + + await authCommand.setKey('a1b2c3d4-e5f6-7890-abcd-ef1234567890:fx', { verify: false }); + + expect(mockConfigService.set).toHaveBeenCalledWith( + 'auth.apiKey', + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890:fx', + ); + expect(mockGetUsage).not.toHaveBeenCalled(); + }); + + it('should still reject an empty key when verification is skipped', async () => { + await expect(authCommand.setKey('', { verify: false })).rejects.toThrow( + 'API key cannot be empty', + ); + }); + + it('should name the offline alternatives when the API is unreachable', async () => { + const mockGetUsage = jest + .fn() + .mockRejectedValue(new NetworkError('connect ECONNREFUSED 127.0.0.1:443')); + (DeepLClient as jest.MockedClass).mockImplementation(() => ({ + getUsage: mockGetUsage, + } as any)); + + await expect(authCommand.setKey('test-key')).rejects.toMatchObject({ + suggestion: expect.stringContaining('--no-verify'), + }); + expect(mockConfigService.set).not.toHaveBeenCalled(); + }); + }); }); describe('getKey()', () => { diff --git a/tests/unit/config-service.test.ts b/tests/unit/config-service.test.ts index c4f7195..8767b30 100644 --- a/tests/unit/config-service.test.ts +++ b/tests/unit/config-service.test.ts @@ -338,6 +338,55 @@ describe('ConfigService', () => { // Cleanup fs.rmSync(uniqueDir, { recursive: true, force: true }); }); + + describe('temp file safety', () => { + // The API key is written in plaintext, so the intermediate file must not + // land on a path anyone else could have prepared in advance. + it('should not write through a symlink planted at the predictable temp path', () => { + const uniqueDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-tmp-safety-')); + const configPath = path.join(uniqueDir, 'config.json'); + const stolenPath = path.join(uniqueDir, 'stolen.txt'); + fs.symlinkSync(stolenPath, `${configPath}.tmp`); + + const service = new ConfigService(configPath); + service.set('auth.apiKey', 'secret-key-value'); + + expect(fs.existsSync(stolenPath)).toBe(false); + expect(fs.lstatSync(configPath).isSymbolicLink()).toBe(false); + expect(fs.readFileSync(configPath, 'utf-8')).toContain('secret-key-value'); + + fs.rmSync(uniqueDir, { recursive: true, force: true }); + }); + + it('should not write to the predictable temp path even when it is free', () => { + const uniqueDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-tmp-unique-')); + const configPath = path.join(uniqueDir, 'config.json'); + const predictablePath = `${configPath}.tmp`; + fs.writeFileSync(predictablePath, 'planted', 'utf-8'); + + const service = new ConfigService(configPath); + service.set('auth.apiKey', 'first'); + service.set('auth.apiKey', 'second'); + + expect(fs.readFileSync(predictablePath, 'utf-8')).toBe('planted'); + expect(fs.readFileSync(configPath, 'utf-8')).toContain('second'); + + fs.rmSync(uniqueDir, { recursive: true, force: true }); + }); + + it('should leave no temp file behind after a successful write', () => { + const uniqueDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-tmp-clean-')); + const configPath = path.join(uniqueDir, 'config.json'); + + const service = new ConfigService(configPath); + service.set('auth.apiKey', 'test'); + + expect(fs.readdirSync(uniqueDir)).toEqual(['config.json']); + expect(fs.existsSync(`${configPath}.tmp`)).toBe(false); + + fs.rmSync(uniqueDir, { recursive: true, force: true }); + }); + }); }); describe('error logging sanitization', () => { diff --git a/tests/unit/prototype-key-safety.test.ts b/tests/unit/prototype-key-safety.test.ts index 2b69853..d81d58b 100644 --- a/tests/unit/prototype-key-safety.test.ts +++ b/tests/unit/prototype-key-safety.test.ts @@ -84,6 +84,38 @@ describe('prototype-named key safety', () => { expect(Object.hasOwn(parsed, key)).toBe(true); expect(parsed[key]).toBe('vorhanden'); }); + + // The cases above reconstruct a key the target already holds. Inserting a + // key the target lacks is a different code path, and the one where plain + // assignment silently drops the translation instead of polluting: on a + // fresh {}, obj['__proto__'] = v retargets that object's prototype, so a + // negative "was Object.prototype polluted" assertion still passes. + it.each(PROTO_KEYS)('should insert a %s key absent from the target as own data', (key) => { + const parser = new JsonFormatParser(); + + const out = parser.reconstruct('{"greeting":"Hallo"}', [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + { key, value: 'Source', translation: 'eingefuegt' }, + ]); + + const parsed = JSON.parse(out) as Record; + expect(Object.hasOwn(parsed, key)).toBe(true); + expect(parsed[key]).toBe('eingefuegt'); + expect(probeOnFreshObject('eingefuegt')).toBeUndefined(); + }); + + it('should insert a nested key under __proto__ as own data', () => { + const parser = new JsonFormatParser(); + + const out = parser.reconstruct('{"greeting":"Hallo"}', [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + { key: '__proto__.nested', value: 'Source', translation: 'verschachtelt' }, + ]); + + const parsed = JSON.parse(out) as Record; + expect(Object.hasOwn(parsed, '__proto__')).toBe(true); + expect((parsed['__proto__'] as Record)['nested']).toBe('verschachtelt'); + }); }); describe('sanitizePullKeysResponse', () => { diff --git a/tests/unit/register-commands-group1.test.ts b/tests/unit/register-commands-group1.test.ts index 7ce8260..81ce9cf 100644 --- a/tests/unit/register-commands-group1.test.ts +++ b/tests/unit/register-commands-group1.test.ts @@ -462,7 +462,7 @@ describe('registerAuth', () => { await loadAndRegister(); await program.parseAsync(['node', 'test', 'auth', 'set-key', 'my-api-key-12345']); - expect(mockSetKey).toHaveBeenCalledWith('my-api-key-12345'); + expect(mockSetKey).toHaveBeenCalledWith('my-api-key-12345', { verify: true }); expect(mockLogger.success).toHaveBeenCalledWith(expect.stringContaining('API key saved')); }); diff --git a/tests/unit/register-init.test.ts b/tests/unit/register-init.test.ts index eea089d..696d91e 100644 --- a/tests/unit/register-init.test.ts +++ b/tests/unit/register-init.test.ts @@ -12,10 +12,19 @@ jest.mock('../../src/cli/commands/init', () => ({ })); describe('registerInit', () => { + const realIsTTY = process.stdin.isTTY; + beforeEach(() => { (InitCommand as jest.Mock).mockImplementation(() => ({ run: jest.fn().mockResolvedValue(undefined), })); + // The wizard requires an interactive terminal; jest workers have none, so + // the tests that reach it declare one explicitly. + process.stdin.isTTY = true; + }); + + afterEach(() => { + process.stdin.isTTY = realIsTTY; }); it('should have the correct description', () => { @@ -61,4 +70,25 @@ describe('registerInit', () => { expect(InitCommand).toHaveBeenCalledWith(configService, undefined); }); + + it('should refuse to start the wizard when stdin is not a terminal', async () => { + process.stdin.isTTY = false; + const program = new Command(); + program.exitOverride(); + const handleError = jest.fn() as jest.Mock & ((error: unknown) => never); + + registerInit(program, { + getConfigService: () => ({}) as ConfigService, + handleError, + }); + + await program.parseAsync(['node', 'deepl', 'init']); + + expect(InitCommand).not.toHaveBeenCalled(); + expect(handleError).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('not supported in non-interactive mode'), + }), + ); + }); }); diff --git a/tests/unit/register-write.test.ts b/tests/unit/register-write.test.ts index a94fd3a..ca0c55e 100644 --- a/tests/unit/register-write.test.ts +++ b/tests/unit/register-write.test.ts @@ -206,6 +206,38 @@ describe('registerWrite', () => { expect(handleError).not.toHaveBeenCalled(); }); + // `deepl languages` prints lowercase codes and `translate --to` lowercases + // before validating, so write rejecting them made the CLI's own output + // unusable with the command documented as consistent with translate. + it.each([ + ['en-us', 'en-US'], + ['en-US', 'en-US'], + ['EN-US', 'en-US'], + ['pt-br', 'pt-BR'], + ['zh-hans', 'zh-Hans'], + ['DE', 'de'], + ])('should accept --lang %s and normalize it to %s', async (input, canonical) => { + mockCreateWriteCommand.mockResolvedValue(mockWriteCommand); + mockWriteCommand.improve.mockResolvedValue('ok'); + await program.parseAsync(['node', 'test', 'write', 'Hello', '--lang', input]); + expect(handleError).not.toHaveBeenCalled(); + expect(mockWriteCommand.improve).toHaveBeenCalledWith( + 'Hello', + expect.objectContaining({ lang: canonical }), + ); + }); + + it('should accept the same casing via --to as via --lang', async () => { + mockCreateWriteCommand.mockResolvedValue(mockWriteCommand); + mockWriteCommand.improve.mockResolvedValue('ok'); + await program.parseAsync(['node', 'test', 'write', 'Hello', '--to', 'pt-br']); + expect(handleError).not.toHaveBeenCalled(); + expect(mockWriteCommand.improve).toHaveBeenCalledWith( + 'Hello', + expect.objectContaining({ lang: 'pt-BR' }), + ); + }); + it('should reject both --style and --tone', async () => { await program.parseAsync([ 'node', 'test', 'write', 'Hello', '--style', 'business', '--tone', 'friendly', From 1c79eab4e95685397071143a30b1dc6e0688ec82 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Thu, 30 Jul 2026 00:00:22 -0400 Subject: [PATCH 06/13] docs: lead installation with npm, and stop emitting unusable sourcemaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README led with a Homebrew command against a tap that does not exist yet, while the release workflow gates its formula-bump job off for exactly that reason — two files in the same commit disagreeing. Homebrew is now listed after npm and marked as pending. TROUBLESHOOTING pointed a user already blocked by an unsupported Node version at that same command as the remedy. sourceMap and declarationMap are disabled: maps are excluded from the published package, so emitting them only left dangling sourceMappingURL comments in 334 shipped files, giving library consumers unresolvable stack frames. SECURITY.md now ships so offline installs carry the reporting channel, and the redundant .npmignore is removed — the files array governs. The changelog entry describing the build clean step named an internal module path, which release.yml would have republished in the v2.0.0 release notes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .npmignore | 20 ------------------- CHANGELOG.md | 2 +- README.md | 20 ++++++++++--------- docs/TROUBLESHOOTING.md | 2 +- package.json | 3 ++- .../cli-translate.integration.test.ts | 8 ++++++++ tsconfig.json | 7 +++++-- 7 files changed, 28 insertions(+), 34 deletions(-) delete mode 100644 .npmignore diff --git a/.npmignore b/.npmignore deleted file mode 100644 index b792107..0000000 --- a/.npmignore +++ /dev/null @@ -1,20 +0,0 @@ -# Build artifacts -dist/*.tsbuildinfo -dist/**/*.tsbuildinfo - -# Source maps -dist/**/*.js.map -dist/**/*.d.ts.map - -# Development files -tsconfig.json -.eslintrc* -eslint.config.* -.prettierrc* -jest.config.* -.github/ -tests/ -examples/ -docs/ -src/ -.beads/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9339f3c..ac60397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING — package**: The package is now published as the scoped **`@deepl/cli`** (previously the unpublished working name `deepl-cli`). Scoped packages default to restricted visibility, so `publishConfig.access: "public"` is set explicitly — without it the publish fails. The `bin` name is unchanged: the command is still `deepl`, and scoping changes only the install string (`npm install -g @deepl/cli`). Repository, bugs, and homepage metadata now point at `github.com/DeepL/deepl-cli` directly instead of relying on the redirect from the legacy `DeepLcom` org name. - **BREAKING — cache/runtime**: The translation cache now uses Node's built-in `node:sqlite` module instead of the `better-sqlite3` native addon, and the CLI consequently requires **Node.js >= 24** (`engines.node` is now `>=24.0.0`). Node 24 is the first release where `node:sqlite` is non-experimental; Node 18/20 lack the module entirely and Node 22 would both warn on every invocation and downgrade the bundled SQLite (3.50.4 vs 3.53.1). Existing cache databases are read in place with no migration — the on-disk format is unchanged (SQLite 3.53.2-written files verified readable, WAL mode and `user_version` stamp included). **Migration**: upgrade to Node 24 (current LTS), e.g. `nvm install 24`; no other action is needed, and the cache keeps its contents. - **ci**: `.nvmrc` now pins Node 24, matching the CI matrix. It still read `20`, so `nvm use` handed local developers a runtime that cannot load `node:sqlite` and does not satisfy `commander`'s `engines.node >=22.12.0`. -- **build**: `npm run build` now runs a `clean` step first, removing `dist/` and `tsconfig.tsbuildinfo` before compiling. Without it, `tsc` leaves output for sources that no longer exist, so a file rename could ship stale artifacts — verified reproducible: planting `dist/sync/strings-client.js` and rebuilding left both files in `npm pack` output. Removing the build-info file is required too; deleting only `dist/` makes the incremental compiler report the output as up to date and emit nothing. +- **build**: `npm run build` now runs a `clean` step first, removing `dist/` and `tsconfig.tsbuildinfo` before compiling. Without it, `tsc` leaves output for sources that no longer exist, so a file rename could ship stale artifacts — verified reproducible: planting a stale module under `dist/sync/` and rebuilding left both files in `npm pack` output. Removing the build-info file is required too; deleting only `dist/` makes the incremental compiler report the output as up to date and emit nothing. - **tests**: Raised the timeout for the `watchAndSync` test block to 30s and replaced its fixed-round setup flush with a wait on an observable readiness signal. These tests intermittently exceeded the 10s default on CI runners — always as timeouts, never assertions — failing unrelated dependency PRs. Suite duration was measured to be flat across 250/25/5 flush rounds, so the historical 20 → 50 → 250 escalation could not have addressed it; the cost is runner CPU starvation (1.8s locally vs 10.8s observed on CI). The flush now also fails with the pending state rather than a bare timeout if setup genuinely stalls. - **deps**: `commander` 14.0.3 → 15.0.0. commander 15 is ESM-only (`"type": "module"`) and requires Node >=22.12.0, so it was unmergeable until the Node 24 baseline landed. Jest's `transformIgnorePatterns` allowlist gains `commander` so ts-jest transforms it under CommonJS test execution; without that, 28 suites fail to load with `SyntaxError: Cannot use import statement outside a module`. - **ci**: Test matrix, release workflow, and security workflow now target Node 24 (previously Node 20 and 22). Node 20 reached end-of-life in April 2026, and Node 24 is the current LTS. This aligns CI with the runtime the project is moving to; the `engines.node` range is unchanged in this entry and is bumped separately. diff --git a/README.md b/README.md index d1e50b7..380975e 100644 --- a/README.md +++ b/README.md @@ -57,15 +57,7 @@ For security policy and vulnerability reporting, see [SECURITY.md](SECURITY.md). ## 📦 Installation -> **Prerequisite:** Node.js **24 or later** (except for Homebrew, which installs Node for you). The cache uses Node's built-in [`node:sqlite`](https://nodejs.org/api/sqlite.html) module — no native compilation, no build toolchain needed. - -### Homebrew (macOS / Linux) - -```bash -brew install deepl/tap/deepl -``` - -Installs the `deepl` command and everything it needs, including Node. +> **Prerequisite:** Node.js **24 or later**. The cache uses Node's built-in [`node:sqlite`](https://nodejs.org/api/sqlite.html) module — no native compilation, no build toolchain needed. ### npm @@ -78,6 +70,16 @@ deepl --version The package is scoped, but the command is just `deepl`. +### Homebrew (macOS / Linux) + +_Not available yet — the tap ships shortly after the first npm release. Use npm in the meantime._ + +```bash +brew install deepl/tap/deepl +``` + +Once available, this installs the `deepl` command and everything it needs, including Node. + ### From Source ```bash diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 613c2f2..d6146ef 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -423,7 +423,7 @@ deepl cache enable Translation and write commands keep working with caching disabled for the run; your cache database is not modified. `deepl cache` subcommands fail until the CLI runs on a supported Node.js version. -**Solution:** run the CLI with Node.js 24+ (e.g. `nvm install 24 && nvm use 24`), or install via Homebrew (`brew install deepl/tap/deepl`), which manages Node for you. +**Solution:** run the CLI with Node.js 24 or later — e.g. `nvm install 24 && nvm use 24`, or install Node 24 from [nodejs.org](https://nodejs.org/). --- diff --git a/package.json b/package.json index 40df8b9..0532040 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "!dist/**/*.js.map", "!dist/**/*.d.ts.map", "README.md", - "LICENSE" + "LICENSE", + "SECURITY.md" ], "bin": { "deepl": "dist/cli/index.js" diff --git a/tests/integration/cli-translate.integration.test.ts b/tests/integration/cli-translate.integration.test.ts index 2388490..969c036 100644 --- a/tests/integration/cli-translate.integration.test.ts +++ b/tests/integration/cli-translate.integration.test.ts @@ -186,6 +186,7 @@ describe('Translate CLI Integration', () => { const outputFile = path.join(testDir, 'output.txt'); fs.writeFileSync(testFile, 'Hello', 'utf-8'); + expect.assertions(1); try { // This will fail without API key, but should recognize valid arguments runCLI( @@ -242,6 +243,7 @@ describe('Translate CLI Integration', () => { fs.mkdirSync(testSubDir2, { recursive: true }); fs.writeFileSync(path.join(testSubDir2, 'file1.txt'), 'Content', 'utf-8'); + expect.assertions(1); try { // This will fail without API key, but should recognize valid arguments runCLI( @@ -375,6 +377,7 @@ describe('Translate CLI Integration', () => { const outputFile = path.join(testDir, 'output.txt'); fs.writeFileSync(txtFile, 'Test content', 'utf-8'); + expect.assertions(1); try { // Will fail without API key but should recognize file type runCLI( @@ -392,6 +395,7 @@ describe('Translate CLI Integration', () => { const outputFile = path.join(testDir, 'output.md'); fs.writeFileSync(mdFile, '# Test\n\nContent', 'utf-8'); + expect.assertions(1); try { // Will fail without API key but should recognize file type runCLI(`deepl translate "${mdFile}" --to es --output "${outputFile}"`, { @@ -424,6 +428,7 @@ describe('Translate CLI Integration', () => { it('should accept --show-billed-characters flag without error', () => { // Verify the flag is recognized (will fail on API key but shouldn't error on unknown flag) + expect.assertions(1); try { runCLI('deepl translate "Hello" --to es --show-billed-characters', { stdio: 'pipe', @@ -449,6 +454,7 @@ describe('Translate CLI Integration', () => { const outputFile = path.join(testDir, 'output.pptx'); fs.writeFileSync(pptxFile, 'Mock PPTX content', 'utf-8'); + expect.assertions(1); try { runCLI( `deepl translate "${pptxFile}" --to es --output "${outputFile}" --enable-minification`, @@ -531,6 +537,7 @@ describe('Translate CLI Integration', () => { args: '--ignore-tags "script,style"', }, ])('should accept $flag flag without error', ({ flag, args }) => { + expect.assertions(1); try { runCLI( `deepl translate "

Hello

" --to es --tag-handling xml ${args}`, @@ -545,6 +552,7 @@ describe('Translate CLI Integration', () => { }); it('should accept all XML tag handling flags together', () => { + expect.assertions(1); try { runCLI( 'deepl translate "

Hello

" --to es --tag-handling xml --outline-detection false --splitting-tags "br,hr" --non-splitting-tags "code" --ignore-tags "script"', diff --git a/tsconfig.json b/tsconfig.json index 200d7ba..4a95b42 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,8 +8,11 @@ /* Emit */ "declaration": true, - "declarationMap": true, - "sourceMap": true, + // Maps are excluded from the published package, so emitting them only + // leaves dangling sourceMappingURL comments in the shipped files. Tests + // and local development map through ts-jest and ts-node, not dist. + "declarationMap": false, + "sourceMap": false, "outDir": "./dist", "rootDir": "./src", "removeComments": true, From f1ef148779625a52c9f706136fe2f644b3614a8a Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Thu, 30 Jul 2026 00:01:55 -0400 Subject: [PATCH 07/13] docs(changelog): record the pre-publish fixes and document auth --no-verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CHANGELOG.md | 16 +++++++++++++++ docs/API.md | 5 +++++ .../cli-translate.integration.test.ts | 20 +++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac60397..ad9d21e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **auth**: `deepl auth set-key --no-verify` stores a key without validating it against the API. Validation ran before persisting, so on a network without proxy configuration both documented setup paths — `auth set-key` and `init` — failed and discarded the key; an unreachable API now also names `DEEPL_API_KEY` as the zero-network alternative. +- **ci**: `npm run check-deps` fails the build when a package imported by `src/` is missing from `dependencies`, including one declared only under `devDependencies`. It runs in CI and in the publish job, and matches package names as quoted strings so indirect loads such as `requireModule('php-parser')` count as references. - **cli**: `t` and `w` command aliases for `translate` and `write` ([#12](https://github.com/DeepL/deepl-cli/issues/12)). The aliases appear in `--help` output and in bash/zsh/fish shell completions. `w` is deliberately assigned to `write` rather than `watch` — write is a primary API feature, watch a workflow helper. - **ci**: Pushing a `v*` tag now runs the full release pipeline. The npm publish job is enabled (it was hard-disabled with `if: false` since its introduction, which is why the repo has 17 tags and zero GitHub Releases); it authenticates with a granular `NPM_TOKEN` for the first publish and keeps `--provenance` attestation. A new, deliberately independent `release` job creates a GitHub Release from the tag, with notes extracted from the version's CHANGELOG section (falling back to generated notes if the section is missing) — a publish failure cannot suppress the Release, and vice versa. A `homebrew` formula-bump job (version + sha256 PR against `DeepL/homebrew-tap`) ships gated with `if: false` until the tap repo and its cross-repo token exist. @@ -40,11 +42,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +- **repo**: The `VERSION` file and `.npmignore`. Nothing read `VERSION` — `deepl --version` reports `package.json`'s value — so it was a second, hand-edited source of truth that could only drift; use `npm version X.Y.Z --no-git-tag-version`, which updates the manifest and lockfile together. `.npmignore` was dead weight because the `files` array governs what is packed. +- **build**: Source maps and declaration maps are no longer emitted. They were already excluded from the published package, so emitting them only left dangling `sourceMappingURL` comments in the shipped files, giving library consumers unresolvable stack frames and broken go-to-definition. +- **deps**: `inquirer`, which no source file imported (see the `@inquirer/prompts` entry under Fixed). - **deps**: `better-sqlite3` and `@types/better-sqlite3`. The production dependency tree no longer contains any native addon, removing the entire class of ABI-mismatch failures (`ERR_DLOPEN_FAILED` / `NODE_MODULE_VERSION` errors after a Node major upgrade, e.g. via `brew upgrade node`), a 1.9 MB platform-specific binary, and the C++ compilation-toolchain requirement for installs from source. The cacheless-degradation safety net remains: a runtime whose `node:sqlite` is missing (Node < 22.5.0) warns once and runs uncached rather than crashing, and never touches the cache database. - **BREAKING — sync**: `deepl sync init --source-lang` and `--target-langs`, the deprecated aliases introduced in 1.x, are removed and now fail with `unknown option` (exit 1). Use `--source-locale` and `--target-locales`. This is the documented removal of 1.x aliases at the 2.0 cut. `deepl translate --target-lang` is unaffected — it is the API's wire name, not a deprecated alias. ### Fixed +- **write**: `deepl write` now runs from a published install. `diff` is imported at the top of the write command but was declared only under `devDependencies`, which consumers do not install, so every command that loaded the module failed with `Cannot find package 'diff'` before producing output. `--help` did not surface it because the module loads lazily. +- **init/write/sync**: `@inquirer/prompts` is declared as a dependency. It is imported at runtime by `init`, `write --interactive` and `sync init` while only the unused `inquirer` was declared, so it resolved through npm's hoisting: under a strict layout (pnpm, `--install-strategy=nested`) those commands failed with `ERR_MODULE_NOT_FOUND`, and under npm the prompt that reads the API key bound to whatever major another dependent happened to hoist. +- **init**: `deepl init` with stdin at end-of-file now exits 6 with the documented non-interactive message, instead of starting to prompt and then exiting 1 with a Node `unsettled top-level await` warning. Reached by `docker run` without `-it`, CI, and piped invocations. The command checked only `--no-input`, where the sibling guard in `write --interactive` also checks whether stdin is a terminal; the existing test covered only the `--no-input` variant, which already worked. +- **write**: `--lang` and `--to` accept language codes in any casing and normalize them to the API's form. They previously compared against a mixed-case list with an exact match, rejecting the lowercase codes `deepl languages` prints and `translate --to` accepts — so the CLI's own discovery output was unusable with the command documented as consistent with `translate`. +- **translate**: The error raised for an unexpected API response pointed at a bug tracker under an unrelated third-party account rather than this repository's. +- **cli**: Shell completions and the did-you-mean suggester no longer offer hidden internal commands, and the suggester now knows about aliases and prefers a prefix match — `deepl tr` suggests `translate` rather than `tm`. `--version` is also no longer duplicated in the bash and zsh candidate lists. +- **cli**: The remediation for a missing API key survives `--quiet`. It was emitted as a warning, which quiet mode suppresses entirely, leaving only `Error: API key not set`; `docs/API.md` claimed quiet mode still showed such warnings and now describes the actual behaviour. - **cache**: The size cap is now actually enforced. Size accounting previously lived in a process-local counter that drifted in both directions — downward when an overwrite's eviction deleted the very key being replaced (after which the cap stopped firing and the DB grew without bound), upward when `get()` deleted expired rows without decrementing (evicting entries that didn't need evicting), and negative when a concurrent process's rows were swept (disabling eviction for the process lifetime). The total is now always read from `SELECT SUM(size)`. Eviction now deletes oldest rows in batches until enough space is actually freed, instead of a one-shot estimate from the average row size that under-evicted whenever sizes were skewed. An entry larger than `maxSize` itself is skipped instead of stored — previously it wiped every other entry first (eviction deleted all rows and inserted the oversized one anyway), leaving a cache that re-wiped on every subsequent write. The expired-entry sweep's throttle timer is seeded so the sweep runs on a process's first operation; it was seeded to construction time, so it never ran in any process shorter than 60 seconds — essentially every CLI invocation. Repeated `getInstance()`/`close()` cycles no longer accumulate process signal listeners until Node prints `MaxListenersExceededWarning` to stderr. Docs now describe eviction as oldest-first rather than LRU, which is what the code has always done (`timestamp` is only written on `set()`). - **cache**: Only genuine corruption (`SQLITE_CORRUPT` / `SQLITE_NOTADB`) now triggers the rename-aside-and-recreate recovery. The constructor previously treated *every* unexpected error as corruption, so transient lock contention (`SQLITE_BUSY` — e.g. two concurrent invocations against a cache on a filesystem where WAL cannot be enabled) renamed a healthy cache aside, recreated it empty, and broke the other process's open transaction; and a DB written by a newer CLI version was destroyed instead of refused, defeating the schema check's stated purpose. Lock waits now go through `PRAGMA busy_timeout` (5s), and everything that isn't corruption propagates so the CLI degrades to an uncached run with a warning. When recovery does run, the backup's WAL/SHM sidecars are copied before the failed handle is closed (SQLite deletes them on close) and named `-wal` / `-shm` so SQLite can actually recover the preserved data — the old naming (`-wal`) orphaned the WAL, silently losing any rows not yet checkpointed. Rename-aside backups are pruned to the most recent three so repeated corruption cannot fill the disk. - **sync**: `deepl sync pull` no longer discards existing translations for keys named after `Object.prototype` members. `sanitizePullKeysResponse` built its result on a plain object, and callers test membership with `pulledKeys[key] !== undefined` / `??` — so for a source key called `toString`, `constructor`, `valueOf`, `hasOwnProperty`, or `__proto__`, the lookup returned an inherited *function*, which is neither `undefined` nor nullish. The entry was therefore treated as a freshly approved TMS translation and won over the real one, then vanished from the file when `JSON.stringify` dropped the function value — while the lockfile recorded it as `translated` / `human_reviewed`, so no later sync repaired it. The accumulator now has a null prototype. @@ -115,6 +127,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- **config**: `ConfigService.save()` writes through an unpredictably named temp file created with an exclusive flag, instead of a fixed `config.json.tmp`. A symlink planted at the predictable path redirected the config — which holds the API key in plaintext — to a path of the planter's choosing, and the subsequent rename left `config.json` as that symlink, so every later write followed it too. The mode is also applied with `chmod` after creation, which the umask cannot widen. +- **tests**: The test suite no longer inherits real credentials or the real config directory. Suites that spawn the bare `deepl` command cannot be intercepted by nock, so they reached the live DeepL API with whatever key was exported and read and wrote the developer's cache database; cached responses matching this suite's fixtures were recovered from a real cache, confirming it had happened. `globalSetup` now clears `DEEPL_API_KEY`, `TMS_API_KEY` and `TMS_TOKEN` and points `DEEPL_CONFIG_DIR` at a temporary directory before workers fork. +- **formats**: The prototype-pollution guard in the JSON parser is now pinned by tests. Replacing `Object.defineProperty` with plain assignment previously left the whole suite green: on a fresh object `obj['__proto__'] = value` retargets that object's prototype rather than `Object.prototype`, so the translation was silently dropped while the negative pollution assertion still passed. +- **ci**: The publish job pins its actions to commit SHAs, since it is the only job holding `NPM_TOKEN`, and refuses to publish when the pushed tag does not match `package.json`. npm's duplicate-version protection cannot catch that mistake on a first publish, which would otherwise ship the wrong version under a provenance attestation naming the tag. The job also now runs lint, type-check, the dependency check and the full suite, because a tag can point at a commit CI never ran. - **translate/sync**: Placeholder restoration no longer hangs the CLI with unbounded memory growth. `restorePlaceholders` looped `while (restored.includes(placeholder))`, replacing one occurrence per pass — so when the preserved original itself contained the placeholder token, every pass re-inserted it and the guard never went false. Measured before the fix: the input `{__VAR_0__}` grew from 9 to 400,009 bytes across 200,000 iterations without converging, and the real function had to be killed. It needed no attacker and no network: `preserveVariables`' pattern matches `{__VAR_0__}` (underscores and digits are in its character class), variable preservation runs unconditionally, and restoration also runs on **cached** results — so a locale value of that shape hung the process with no API call. Each placeholder is now restored in a single pass, using the function form of the replacement so `$&`/`$1` inside a preserved value stay literal. - **sync**: Bucket `include` globs can no longer escape the project root, and `--dry-run` no longer modifies the working tree. `include` entries were validated only as non-empty strings, while `target_path_pattern` a few lines later already rejected `..`. The unvalidated glob's literal prefix was resolved and handed to the stale-`.bak` sweep, which recursed with **no containment check** — deleting every old `*.bak` it found and *re-creating* any file whose `.bak` existed while the live file was missing or empty. Verified: `include: "../../../../../../**/*.json"` produced a sweep root of `/var`, an out-of-root `.bak` was deleted and its sibling resurrected with the backup's contents. Two things made it worse: the sweep was gated only on watch runs, so it ran under `--dry-run` — the flag a cautious user reaches for to avoid side effects — and its errors were swallowed entirely, so it was silent. `include` entries are now rejected at config load for traversal segments and absolute paths (the check the `sync init` wizard already applied, which simply did not exist on the load path), the sweep independently refuses any root outside the project and logs the attempt, the sweep is skipped under `--dry-run`, and its failures are reported instead of discarded. - **deps**: Resolved four production-dependency advisories flagged by `npm audit --omit=dev` via lockfile-only transitive bumps (no `package.json` ranges changed): `brace-expansion` 5.0.5 → 5.0.6 (GHSA-jxxr-4gwj-5jf2, ReDoS), `form-data` → 4.0.6 (GHSA-hmw2-7cc7-3qxx, CRLF injection), and `ws` 8.20.0 → 8.21.0 (GHSA-58qx-3vcg-4xpx and GHSA-96hv-2xvq-fx4p, uninitialized-memory disclosure and memory-exhaustion DoS). Production `npm audit` is back to zero vulnerabilities, unblocking the CI audit gate. diff --git a/docs/API.md b/docs/API.md index eb099d4..7bcc737 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2440,6 +2440,7 @@ Set your DeepL API key and validate it with the DeepL API. **Options:** - `--from-stdin` - Read API key from stdin +- `--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:** @@ -2453,6 +2454,10 @@ 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`). diff --git a/tests/integration/cli-translate.integration.test.ts b/tests/integration/cli-translate.integration.test.ts index 969c036..78af47a 100644 --- a/tests/integration/cli-translate.integration.test.ts +++ b/tests/integration/cli-translate.integration.test.ts @@ -573,6 +573,7 @@ describe('Translate CLI Integration', () => { }); it('should accept --format table flag without error', () => { + expect.assertions(2); try { runCLI('deepl translate "Hello" --to es,fr,de --format table', { stdio: 'pipe', @@ -585,6 +586,7 @@ describe('Translate CLI Integration', () => { }); it('should recognize table format as valid option', () => { + expect.assertions(2); try { runCLI('deepl translate "Test" --to es,fr --format table', { stdio: 'pipe', @@ -597,6 +599,7 @@ describe('Translate CLI Integration', () => { }); it('should work with multiple target languages', () => { + expect.assertions(2); try { runCLI( 'deepl translate "Hello world" --to es,fr,de,ja --format table', @@ -610,6 +613,7 @@ describe('Translate CLI Integration', () => { }); it('should accept table format with other options', () => { + expect.assertions(1); try { runCLI( 'deepl translate "Test" --to es,fr --format table --formality more --context "Business email"', @@ -622,6 +626,7 @@ describe('Translate CLI Integration', () => { }); it('should accept table format with --show-billed-characters', () => { + expect.assertions(2); try { runCLI( 'deepl translate "Test" --to es,fr,de --format table --show-billed-characters --no-cache', @@ -642,6 +647,7 @@ describe('Translate CLI Integration', () => { }); it('should accept a single --custom-instruction flag', () => { + expect.assertions(1); try { runCLI( 'deepl translate "Hello" --to es --custom-instruction "Use informal tone"', @@ -654,6 +660,7 @@ describe('Translate CLI Integration', () => { }); it('should accept multiple --custom-instruction flags', () => { + expect.assertions(1); try { runCLI( 'deepl translate "Hello" --to es --custom-instruction "Use informal tone" --custom-instruction "Preserve brand names"', @@ -666,6 +673,7 @@ describe('Translate CLI Integration', () => { }); it('should accept --custom-instruction with other options', () => { + expect.assertions(1); try { runCLI( 'deepl translate "Hello" --to es --formality more --custom-instruction "Keep it formal" --model-type quality_optimized', @@ -685,6 +693,7 @@ describe('Translate CLI Integration', () => { }); it('should accept --style-id flag', () => { + expect.assertions(1); try { runCLI('deepl translate "Hello" --to es --style-id "abc-123-def"', { stdio: 'pipe', @@ -696,6 +705,7 @@ describe('Translate CLI Integration', () => { }); it('should accept --style-id with other options', () => { + expect.assertions(1); try { runCLI( 'deepl translate "Hello" --to es --style-id "abc-123" --formality more', @@ -715,6 +725,7 @@ describe('Translate CLI Integration', () => { }); it('should accept --enable-beta-languages flag', () => { + expect.assertions(1); try { runCLI('deepl translate "Hello" --to es --enable-beta-languages', { stdio: 'pipe', @@ -726,6 +737,7 @@ describe('Translate CLI Integration', () => { }); it('should accept --enable-beta-languages with other options', () => { + expect.assertions(1); try { runCLI( 'deepl translate "Hello" --to es --enable-beta-languages --formality more', @@ -770,6 +782,7 @@ describe('Translate CLI Integration', () => { describe('expanded language support', () => { it('should accept extended language codes', () => { + expect.assertions(1); try { runCLI('deepl translate "Hello" --to sw', { stdio: 'pipe' }); } catch (error: any) { @@ -779,6 +792,7 @@ describe('Translate CLI Integration', () => { }); it('should accept ES-419 target variant', () => { + expect.assertions(1); try { runCLI('deepl translate "Hello" --to es-419', { stdio: 'pipe' }); } catch (error: any) { @@ -788,6 +802,7 @@ describe('Translate CLI Integration', () => { }); it('should accept zh-hans and zh-hant variants', () => { + expect.assertions(1); try { runCLI('deepl translate "Hello" --to zh-hans', { stdio: 'pipe' }); } catch (error: any) { @@ -797,6 +812,7 @@ describe('Translate CLI Integration', () => { }); it('should accept newly added core languages (he, vi)', () => { + expect.assertions(1); try { runCLI('deepl translate "Hello" --to he', { stdio: 'pipe' }); } catch (error: any) { @@ -813,6 +829,7 @@ describe('Translate CLI Integration', () => { }); it('should accept --tag-handling-version with --tag-handling', () => { + expect.assertions(1); try { runCLI( 'deepl translate "

Hello

" --to es --tag-handling html --tag-handling-version v2', @@ -852,6 +869,7 @@ describe('Translate CLI Integration', () => { }); it('should accept https:// URLs', () => { + expect.assertions(1); try { runCLIWithKey( 'deepl translate "Hello" --to es --api-url https://api-free.deepl.com/v2' @@ -863,6 +881,7 @@ describe('Translate CLI Integration', () => { }); it('should allow http://localhost for local testing', () => { + expect.assertions(1); try { runCLIWithKey( 'deepl translate "Hello" --to es --api-url http://localhost:3000/v2' @@ -874,6 +893,7 @@ describe('Translate CLI Integration', () => { }); it('should allow http://127.0.0.1 for local testing', () => { + expect.assertions(1); try { runCLIWithKey( 'deepl translate "Hello" --to es --api-url http://127.0.0.1:5000/v2' From de0c1264c139d6253f4376a6d7b8d0174807ff88 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Thu, 30 Jul 2026 00:04:00 -0400 Subject: [PATCH 08/13] test: gate documented CLI surface and reject a stale build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs were accurate at this cut, but only because a human checked every invocation by hand; nothing failed when a command or flag drifted. The new suite reads the real surface from the hidden _describe command and checks every `deepl …` line in README, API, SYNC and TROUBLESHOOTING against it. Verified it fails on both an unknown command and an unknown flag. require-build now also compares dist's mtime against the newest file in src. It previously checked existence only, while its own comment named the worse hazard: the suites execute dist/cli/index.js, so a stale build reports results that do not describe the current source. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../cli-glossary.integration.test.ts | 5 + .../cli-translate.integration.test.ts | 4 + tests/require-build.ts | 66 +++++---- tests/unit/docs/documented-surface.test.ts | 126 ++++++++++++++++++ 4 files changed, 178 insertions(+), 23 deletions(-) create mode 100644 tests/unit/docs/documented-surface.test.ts diff --git a/tests/integration/cli-glossary.integration.test.ts b/tests/integration/cli-glossary.integration.test.ts index 91c971e..0fb0eaf 100644 --- a/tests/integration/cli-glossary.integration.test.ts +++ b/tests/integration/cli-glossary.integration.test.ts @@ -91,6 +91,7 @@ describe('Glossary CLI Integration', () => { const tsvFile = path.join(testDir, 'glossary.tsv'); fs.writeFileSync(tsvFile, 'Hello\tHola\nWorld\tMundo\n', 'utf-8'); + expect.assertions(2); try { // Will fail without API key but should recognize file type runCLI(`deepl glossary create "Test" en es "${tsvFile}"`, { @@ -108,6 +109,7 @@ describe('Glossary CLI Integration', () => { const csvFile = path.join(testDir, 'glossary.csv'); fs.writeFileSync(csvFile, 'Hello,Hola\nWorld,Mundo\n', 'utf-8'); + expect.assertions(1); try { // Will fail without API key but should recognize file type runCLI(`deepl glossary create "Test" en es "${csvFile}"`, { @@ -124,6 +126,7 @@ describe('Glossary CLI Integration', () => { const tsvFile = path.join(testDir, 'glossary-multi.tsv'); fs.writeFileSync(tsvFile, 'Hello\tHola\nWorld\tMundo\n', 'utf-8'); + expect.assertions(2); try { runCLI(`deepl glossary create "MultiTest" en de,fr,es "${tsvFile}"`, { stdio: 'pipe', @@ -145,6 +148,7 @@ describe('Glossary CLI Integration', () => { }); it('should not require any arguments', () => { + expect.assertions(2); try { // Will fail without API key runCLI('deepl glossary list', { stdio: 'pipe' }); @@ -354,6 +358,7 @@ describe('Glossary CLI Integration', () => { }); it('should not require any arguments', () => { + expect.assertions(2); try { // Will fail without API key runCLI('deepl glossary languages', { stdio: 'pipe' }); diff --git a/tests/integration/cli-translate.integration.test.ts b/tests/integration/cli-translate.integration.test.ts index 78af47a..af2217f 100644 --- a/tests/integration/cli-translate.integration.test.ts +++ b/tests/integration/cli-translate.integration.test.ts @@ -933,6 +933,7 @@ describe('Translate CLI Integration', () => { 'prefer_more', 'prefer_less', ]; + expect.assertions(validValues.length); for (const value of validValues) { try { runCLI(`deepl translate "Hello" --to es --formality ${value}`, { @@ -963,6 +964,7 @@ describe('Translate CLI Integration', () => { it('should accept valid --tag-handling values', () => { const validValues = ['xml', 'html']; + expect.assertions(validValues.length); for (const value of validValues) { try { runCLI( @@ -999,6 +1001,7 @@ describe('Translate CLI Integration', () => { 'prefer_quality_optimized', 'latency_optimized', ]; + expect.assertions(validValues.length); for (const value of validValues) { try { runCLI(`deepl translate "Hello" --to es --model-type ${value}`, { @@ -1030,6 +1033,7 @@ describe('Translate CLI Integration', () => { it('should accept valid --split-sentences values', () => { const validValues = ['on', 'off', 'nonewlines']; + expect.assertions(validValues.length); for (const value of validValues) { try { runCLI(`deepl translate "Hello" --to es --split-sentences ${value}`, { diff --git a/tests/require-build.ts b/tests/require-build.ts index 4ac9380..65aa452 100644 --- a/tests/require-build.ts +++ b/tests/require-build.ts @@ -1,33 +1,53 @@ /** - * Fails fast with an actionable message when tests run without a build: - * dist/ is gitignored and `npm test` does not build, yet all three test - * tiers execute dist/cli/index.js, so a missing build otherwise surfaces - * as hundreds of unrelated failures. + * Fails fast with an actionable message when tests run without a current + * build: dist/ is gitignored and `npm test` does not build, yet all three + * test tiers execute dist/cli/index.js. A missing build otherwise surfaces + * as hundreds of unrelated failures, and a stale one silently tests the + * previously built CLI rather than the current source. */ import * as fs from 'fs'; import * as path from 'path'; -const CLI_ENTRY = path.join(process.cwd(), 'dist', 'cli', 'index.js'); - -export default function requireBuild(): void { - if (fs.existsSync(CLI_ENTRY)) return; +const ROOT = process.cwd(); +const CLI_ENTRY = path.join(ROOT, 'dist', 'cli', 'index.js'); +const SRC = path.join(ROOT, 'src'); +function fail(reason: string, detail: string): never { throw new Error( - [ - '', - 'Tests require a build, but dist/cli/index.js does not exist.', - '', - ` Expected: ${CLI_ENTRY}`, - '', - 'Run:', - '', - ' npm run build && npm test', - '', - 'dist/ is gitignored, so a fresh checkout or a pull has no built output.', - 'Note that a STALE dist is also a hazard: it makes the E2E suites test', - 'the previously built CLI rather than your current source.', - '', - ].join('\n'), + ['', reason, '', detail, '', 'Run:', '', ' npm run build && npm test', ''].join('\n'), ); } + +function newestModified(dir: string): number { + let newest = 0; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const mtime = entry.isDirectory() + ? newestModified(full) + : fs.statSync(full).mtimeMs; + if (mtime > newest) { + newest = mtime; + } + } + return newest; +} + +export default function requireBuild(): void { + if (!fs.existsSync(CLI_ENTRY)) { + fail( + 'Tests require a build, but dist/cli/index.js does not exist.', + ` Expected: ${CLI_ENTRY}\n\ndist/ is gitignored, so a fresh checkout or a pull has no built output.`, + ); + } + + const builtAt = fs.statSync(CLI_ENTRY).mtimeMs; + const sourceChangedAt = newestModified(SRC); + + if (sourceChangedAt > builtAt) { + fail( + 'Tests require a current build, but src/ is newer than dist/.', + 'The suites execute dist/cli/index.js, so they would test the previously\nbuilt CLI and report results that do not describe your changes.', + ); + } +} diff --git a/tests/unit/docs/documented-surface.test.ts b/tests/unit/docs/documented-surface.test.ts new file mode 100644 index 0000000..24a1945 --- /dev/null +++ b/tests/unit/docs/documented-surface.test.ts @@ -0,0 +1,126 @@ +/** + * Checks every `deepl …` invocation in the user-facing docs against the CLI's + * real surface, reported by the hidden `_describe` command. + * + * The docs were accurate at the 2.0.0 cut, but only because someone checked by + * hand. This makes a documented command or flag that no longer exists a test + * failure instead of something a reader discovers. + */ + +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +interface DescribedCommand { + name: string; + aliases: string[]; + options: { flags: string }[]; + commands: DescribedCommand[]; +} + +const ROOT = path.join(__dirname, '..', '..', '..'); +const CLI_ENTRY = path.join(ROOT, 'dist', 'cli', 'index.js'); +const DOCS = ['README.md', 'docs/API.md', 'docs/SYNC.md', 'docs/TROUBLESHOOTING.md']; + +/** Deliberate misspellings used to demonstrate did-you-mean suggestions. */ +const TYPO_EXAMPLES = new Set(['transalte', 'translte', 'glossry', 'conifg', 'descibe']); + +function longFlags(options: { flags: string }[]): string[] { + return options.flatMap((option) => option.flags.match(/--[a-z0-9-]+/g) ?? []); +} + +function describeSurface(): DescribedCommand { + const raw = execFileSync(process.execPath, [CLI_ENTRY, '_describe', '--format', 'json'], { + encoding: 'utf-8', + }); + return JSON.parse(raw) as DescribedCommand; +} + +describe('documented CLI surface', () => { + let surface: DescribedCommand; + let globalFlags: Set; + + beforeAll(() => { + surface = describeSurface(); + globalFlags = new Set([...longFlags(surface.options), '--help', '--version']); + }); + + function resolveCommand(tokens: string[]): DescribedCommand | undefined { + let current: DescribedCommand | undefined = surface; + for (const token of tokens) { + current = current?.commands.find( + (candidate) => candidate.name === token || candidate.aliases.includes(token), + ); + if (!current) return undefined; + } + return current; + } + + /** Command tokens are the words before the first flag or shell operator. */ + function commandTokens(invocation: string): string[] { + const tokens: string[] = []; + for (const token of invocation.split(/\s+/)) { + if (token.startsWith('-') || /^[|&><$"'`]/.test(token)) break; + tokens.push(token); + } + return tokens; + } + + function invocations(markdown: string): string[] { + return markdown + .split('\n') + .map((line) => line.replace(/^\s*[$>]\s*/, '').trim()) + .filter((line) => line.startsWith('deepl ')) + .map((line) => line.slice('deepl '.length)); + } + + describe.each(DOCS)('%s', (docPath) => { + let documented: string[]; + + beforeAll(() => { + documented = invocations(fs.readFileSync(path.join(ROOT, docPath), 'utf-8')); + }); + + it('documents at least one invocation', () => { + expect(documented.length).toBeGreaterThan(0); + }); + + it('references only commands the CLI provides', () => { + const unknown = documented.filter((invocation) => { + const tokens = commandTokens(invocation); + if (tokens.length === 0 || TYPO_EXAMPLES.has(tokens[0]!)) return false; + return resolveCommand(tokens) === undefined && resolveCommand([tokens[0]!]) === undefined; + }); + + expect(unknown).toEqual([]); + }); + + it('references only flags the CLI accepts', () => { + const unknown: string[] = []; + + for (const invocation of documented) { + const tokens = commandTokens(invocation); + if (tokens.length === 0 || TYPO_EXAMPLES.has(tokens[0]!)) continue; + + const command = resolveCommand(tokens) ?? resolveCommand([tokens[0]!]); + if (!command) continue; + + const accepted = new Set([...longFlags(command.options), ...globalFlags]); + // A flag may belong to a subcommand named after the first flag-free + // tokens, so accept anything the parent chain declares too. + for (let depth = 1; depth < tokens.length; depth++) { + const ancestor = resolveCommand(tokens.slice(0, depth)); + if (ancestor) longFlags(ancestor.options).forEach((flag) => accepted.add(flag)); + } + + for (const flag of invocation.match(/--[a-z0-9-]+/g) ?? []) { + if (!accepted.has(flag)) { + unknown.push(`${invocation} -> ${flag}`); + } + } + } + + expect(unknown).toEqual([]); + }); + }); +}); From 6d50d3396b8647a64d53c272feafd7a9a785ec6f Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Thu, 30 Jul 2026 00:11:45 -0400 Subject: [PATCH 09/13] feat(http): honour NO_PROXY, and correct four documentation inaccuracies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A corporate HTTPS_PROXY was applied to every request, including one aimed at localhost, because no bypass list was consulted. NO_PROXY and no_proxy are now matched against the target host with the standard semantics: * for everything, a leading dot or *. for subdomains, and an optional port that must agree. Docs: the exit-code classifier list is reordered to the sequence the code actually tests, which is what decides the code when a message matches more than one pattern; the global-options table gains --timeout and --max-retries, which were documented only in a later section; the quick start now leads with --from-stdin rather than the argument form the CLI warns about; and the bash completion instructions no longer imply an unprivileged write to /etc/bash_completion.d, and name the bash-completion package the generated script needs to do anything. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 23 ++++++++++--- docs/API.md | 4 ++- src/api/http-client.ts | 45 ++++++++++++++++++++++++-- tests/unit/http-client.test.ts | 59 ++++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 380975e..2a61c14 100644 --- a/README.md +++ b/README.md @@ -117,10 +117,12 @@ deepl init Or set your API key directly: ```bash -deepl auth set-key YOUR_API_KEY - -# Recommended: pipe key from stdin to avoid exposing it in process listings +# 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 use an environment variable: @@ -151,6 +153,8 @@ DeepL CLI supports global flags that work with all commands: | `--verbose` | `-v` | Show extra information | | `--config FILE` | `-c` | Use alternate configuration file | | `--no-input` | | Disable all interactive prompts (abort instead of prompting) | +| `--timeout MS` | | HTTP request timeout in milliseconds (default 30000) | +| `--max-retries N` | | Retries per failed request (default 3) | ### Verbose Mode @@ -1076,6 +1080,9 @@ deepl translate "Hello" --to es # Both HTTP_PROXY and HTTPS_PROXY are supported (case-insensitive) export http_proxy=http://proxy.example.com:8080 export https_proxy=https://proxy.example.com:8443 + +# Exempt hosts from the proxy with NO_PROXY (comma-separated) +export NO_PROXY=localhost,127.0.0.1,.internal.example.com ``` **Features:** @@ -1083,6 +1090,7 @@ export https_proxy=https://proxy.example.com:8443 - ✅ Automatic proxy detection from environment variables - ✅ HTTP and HTTPS proxy support - ✅ Proxy authentication support +- ✅ `NO_PROXY` / `no_proxy` bypass, including `*`, a leading dot or `*.` for subdomains, and optional `host:port` entries - ✅ Follows standard proxy environment variable conventions - ✅ Works with all DeepL CLI commands @@ -1379,8 +1387,8 @@ deepl admin usage --start 2024-01-01 --end 2024-12-31 --format json Generate shell completion scripts for tab-completion of commands, options, and arguments: ```bash -# Bash -deepl completion bash > /etc/bash_completion.d/deepl +# Bash (system-wide; needs write access to that directory) +deepl completion bash | sudo tee /etc/bash_completion.d/deepl > /dev/null # Zsh deepl completion zsh > "${fpath[1]}/_deepl" @@ -1392,6 +1400,11 @@ deepl completion fish > ~/.config/fish/completions/deepl.fish source <(deepl completion bash) ``` +> **Bash only:** the generated script uses `_init_completion`, which the +> [bash-completion](https://github.com/scop/bash-completion) package provides. Without it +> loaded the script is a no-op rather than an error. Install it with +> `brew install bash-completion@2` (macOS) or your distribution's `bash-completion` package. + ### Cache Management The CLI uses a local SQLite database to cache translations and write improvements, reducing API calls. diff --git a/docs/API.md b/docs/API.md index 7bcc737..bcfe6c0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3197,11 +3197,13 @@ When an error reaches the top-level handler without being a `DeepLCLIError`, the - **3 — RateLimitError**: `rate limit exceeded`, `too many requests`, `\b429\b` - **4 — QuotaError**: `quota exceeded`, `character limit reached`, `\b456\b` - **5 — NetworkError**: `econnrefused`, `enotfound`, `econnreset`, `etimedout`, `socket hang up`, `network error`, `network timeout`, `connection refused`, `connection reset`, `connection timed out`, `service temporarily unavailable`, `\b503\b` +- **9 — VoiceError**: `voice api`, `voice session` - **7 — ConfigError** (checked before 6 because config messages may contain "invalid"): `config file`, `config directory`, `configuration file`, `configuration error`, `failed to load config`, `failed to save config`, `failed to read config` - **6 — InvalidInput**: `cannot be empty`, `file not found`, `path not found`, `directory not found`, `not found in glossary`, `unsupported format`, `unsupported language`, `not supported for`, `not supported in`, `invalid target language`, `invalid source language`, `invalid language code`, `invalid glossary`, `invalid hook`, `invalid url`, `invalid size`, `is required`, `cannot specify both` -- **9 — VoiceError**: `voice api`, `voice session` - **1 — GeneralError**: anything not matched above +The list is in the order the classifier actually tests, which is what determines the code when a message matches more than one pattern. + ### Trace IDs API error messages include the DeepL `X-Trace-ID` header when available, which is useful when contacting DeepL support: diff --git a/src/api/http-client.ts b/src/api/http-client.ts index ee87ef0..52055ff 100644 --- a/src/api/http-client.ts +++ b/src/api/http-client.ts @@ -121,7 +121,48 @@ export class HttpClient { protected totalTimeout: number; protected _lastTraceId?: string; - private static parseProxyFromEnv(): ProxyConfig | undefined { + /** + * Standard NO_PROXY matching: `*` bypasses everything, a leading dot or `*.` + * matches subdomains, and an entry may carry a port. Without this a corporate + * HTTPS_PROXY was applied even to a localhost endpoint. + */ + private static isProxyBypassed(targetUrl: string): boolean { + const noProxy = process.env['NO_PROXY'] ?? process.env['no_proxy']; + if (!noProxy) return false; + + let target: URL; + try { + target = new URL(targetUrl); + } catch { + return false; + } + + const host = target.hostname.toLowerCase(); + const port = target.port || (target.protocol === 'https:' ? '443' : '80'); + + return noProxy + .split(',') + .map((entry) => entry.trim().toLowerCase()) + .filter((entry) => entry.length > 0) + .some((entry) => { + if (entry === '*') return true; + + const [entryHost, entryPort] = entry.split(':'); + if (entryPort !== undefined && entryPort !== port) return false; + + const pattern = (entryHost ?? '').replace(/^\*\./, '.'); + if (pattern.startsWith('.')) { + return host === pattern.slice(1) || host.endsWith(pattern); + } + return host === pattern; + }); + } + + private static parseProxyFromEnv(targetUrl?: string): ProxyConfig | undefined { + if (targetUrl !== undefined && HttpClient.isProxyBypassed(targetUrl)) { + return undefined; + } + const httpProxy = process.env['HTTP_PROXY'] ?? process.env['http_proxy']; const httpsProxy = process.env['HTTPS_PROXY'] ?? process.env['https_proxy']; const proxyUrl = httpsProxy ?? httpProxy; @@ -203,7 +244,7 @@ export class HttpClient { }), }; - const proxyConfig = options.proxy ?? HttpClient.parseProxyFromEnv(); + const proxyConfig = options.proxy ?? HttpClient.parseProxyFromEnv(baseURL); if (proxyConfig) { // SECURITY: a plain-http proxy sitting in front of an https: API diff --git a/tests/unit/http-client.test.ts b/tests/unit/http-client.test.ts index 69212cc..c791569 100644 --- a/tests/unit/http-client.test.ts +++ b/tests/unit/http-client.test.ts @@ -291,4 +291,63 @@ describe('HttpClient', () => { } }); }); + + describe('NO_PROXY', () => { + const PROXY_VARS = ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'NO_PROXY', 'no_proxy']; + let saved: Record; + + beforeEach(() => { + saved = Object.fromEntries(PROXY_VARS.map((name) => [name, process.env[name]])); + PROXY_VARS.forEach((name) => delete process.env[name]); + process.env['HTTPS_PROXY'] = 'http://proxy.example.com:8080'; + }); + + afterEach(() => { + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + }); + + function proxyFor(baseUrl: string): unknown { + const instance = new TestHttpClient(apiKey, { baseUrl }); + return (instance as unknown as { client: { defaults: { proxy?: unknown } } }).client.defaults + .proxy; + } + + it('uses the proxy when NO_PROXY does not match', () => { + process.env['NO_PROXY'] = 'example.org'; + + expect(proxyFor('https://api.deepl.com')).toMatchObject({ host: 'proxy.example.com' }); + }); + + it.each([ + ['exact host', 'api.deepl.com', 'https://api.deepl.com'], + ['wildcard', '*', 'https://api.deepl.com'], + ['leading dot subdomain', '.deepl.com', 'https://api.deepl.com'], + ['star-dot subdomain', '*.deepl.com', 'https://api.deepl.com'], + ['host with matching port', 'localhost:8080', 'http://localhost:8080'], + ['entry among several', 'foo.test,api.deepl.com,bar.test', 'https://api.deepl.com'], + ['case-insensitive', 'API.DeepL.com', 'https://api.deepl.com'], + ])('bypasses the proxy for %s', (_label, noProxy, target) => { + process.env['NO_PROXY'] = noProxy; + + expect(proxyFor(target)).toBeUndefined(); + }); + + it('does not bypass when only the port differs', () => { + process.env['NO_PROXY'] = 'localhost:9999'; + + expect(proxyFor('http://localhost:8080')).toMatchObject({ host: 'proxy.example.com' }); + }); + + it('honours the lowercase no_proxy spelling', () => { + process.env['no_proxy'] = 'api.deepl.com'; + + expect(proxyFor('https://api.deepl.com')).toBeUndefined(); + }); + }); }); From 1dc66e5c1e20b47acdbe83c312093f1e07d4a3a6 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Thu, 30 Jul 2026 00:24:09 -0400 Subject: [PATCH 10/13] test: guard catch-only assertions against silently passing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These tests assert only inside a catch block, so they passed with zero assertions whenever the command under test unexpectedly succeeded — the failure they exist to detect was the one they could not see. Each now declares the number of assertions its catch block runs. Verified the guard bites: making one guarded command stop failing turns the test red where it previously stayed green. Sites that already assert outside the try, or that call fail() on the success path, are left alone — they cannot pass vacuously, so a guard there adds nothing but noise. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tests/e2e/cli-auth.e2e.test.ts | 1 + tests/e2e/cli-structured-file.e2e.test.ts | 3 +++ tests/e2e/cli-workflow.e2e.test.ts | 11 +++++++++-- tests/e2e/cli-write.e2e.test.ts | 6 ++++++ .../integration/cli-auth.integration.test.ts | 1 + ...i-document-translation.integration.test.ts | 3 +++ .../cli-glossary.integration.test.ts | 11 +++++++++++ .../cli-languages.integration.test.ts | 1 + ...uctured-file-translate.integration.test.ts | 5 +++++ .../cli-style-rules.integration.test.ts | 12 ++++++++++++ .../deepl-client.integration.test.ts | 1 + .../sync-template-prep.integration.test.ts | 1 + tests/unit/admin-client.test.ts | 2 ++ tests/unit/deepl-client.test.ts | 1 + tests/unit/glossary-client.test.ts | 1 + tests/unit/glossary-service.test.ts | 2 ++ tests/unit/register-commands-group1.test.ts | 1 + tests/unit/sync/sync-command.test.ts | 1 + tests/unit/sync/sync-config.test.ts | 2 ++ tests/unit/sync/sync-init-validate.test.ts | 2 ++ tests/unit/translation-client.test.ts | 1 + tests/unit/translation-memory.test.ts | 2 ++ tests/unit/utils/glob-prefix.test.ts | 1 + tests/unit/watch-command.test.ts | 19 +++++++++++++++++++ 24 files changed, 89 insertions(+), 2 deletions(-) diff --git a/tests/e2e/cli-auth.e2e.test.ts b/tests/e2e/cli-auth.e2e.test.ts index f23a90c..b79b6a7 100644 --- a/tests/e2e/cli-auth.e2e.test.ts +++ b/tests/e2e/cli-auth.e2e.test.ts @@ -37,6 +37,7 @@ describe('Auth Command E2E', () => { describe('auth show', () => { it('should show "No API key set" when no key configured', () => { + expect.assertions(1); // Clear any existing key first try { runCLI('auth clear'); diff --git a/tests/e2e/cli-structured-file.e2e.test.ts b/tests/e2e/cli-structured-file.e2e.test.ts index 969898d..ce7ca38 100644 --- a/tests/e2e/cli-structured-file.e2e.test.ts +++ b/tests/e2e/cli-structured-file.e2e.test.ts @@ -20,6 +20,7 @@ describe('Structured File Translation E2E', () => { describe('JSON file support', () => { it('should accept JSON file input without "unsupported file type" error', () => { + expect.assertions(1); const inputPath = path.join(testDir, 'test.json'); const outputPath = path.join(testDir, 'test-es.json'); @@ -106,6 +107,7 @@ describe('Structured File Translation E2E', () => { describe('YAML file support', () => { it('should accept YAML file input without "unsupported file type" error', () => { + expect.assertions(1); const inputPath = path.join(testDir, 'test.yaml'); const outputPath = path.join(testDir, 'test-es.yaml'); @@ -120,6 +122,7 @@ describe('Structured File Translation E2E', () => { }); it('should accept .yml file input without "unsupported file type" error', () => { + expect.assertions(1); const inputPath = path.join(testDir, 'test.yml'); const outputPath = path.join(testDir, 'test-es.yml'); diff --git a/tests/e2e/cli-workflow.e2e.test.ts b/tests/e2e/cli-workflow.e2e.test.ts index 5b1b248..bbf0796 100644 --- a/tests/e2e/cli-workflow.e2e.test.ts +++ b/tests/e2e/cli-workflow.e2e.test.ts @@ -297,8 +297,8 @@ describe('CLI Workflow E2E', () => { // Step 3: Show should now indicate no key is set try { - runCLI('deepl auth show'); - // If it doesn't throw, check that it says "not set" or similar + const output = runCLI('deepl auth show'); + expect(output).toMatch(/No API key set|not set|not configured/i); } catch (error: any) { const output = error.stderr ?? error.stdout; expect(output).toMatch(/not set|not configured/i); @@ -534,6 +534,7 @@ describe('CLI Workflow E2E', () => { describe('entry editing workflow', () => { it('should support complete workflow: create → add → update → remove', () => { + expect.assertions(6); // Clear API key to test the workflow structure (will fail on auth) try { runCLI('deepl auth clear'); @@ -570,6 +571,7 @@ describe('CLI Workflow E2E', () => { }); it('should accept glossary ID in addition to name', () => { + expect.assertions(1); // Clear API key try { runCLI('deepl auth clear'); @@ -767,6 +769,7 @@ describe('CLI Workflow E2E', () => { }); it('should accept --detailed and pagination flags', () => { + expect.assertions(1); try { runCLI('deepl style-rules list --detailed --page 1 --page-size 10'); } catch (error: any) { @@ -778,6 +781,7 @@ describe('CLI Workflow E2E', () => { describe('Expanded Language Support', () => { it('should accept extended language codes like Swahili', () => { + expect.assertions(1); try { runCLI('deepl translate "Hello" --to sw'); } catch (error: any) { @@ -787,6 +791,7 @@ describe('CLI Workflow E2E', () => { }); it('should accept ES-419 Latin American Spanish', () => { + expect.assertions(1); try { runCLI('deepl translate "Hello" --to es-419'); } catch (error: any) { @@ -796,6 +801,7 @@ describe('CLI Workflow E2E', () => { }); it('should accept Chinese simplified/traditional variants', () => { + expect.assertions(1); try { runCLI('deepl translate "Hello" --to zh-hant'); } catch (error: any) { @@ -807,6 +813,7 @@ describe('CLI Workflow E2E', () => { describe('Tag Handling Version', () => { it('should accept --tag-handling-version flag', () => { + expect.assertions(1); try { runCLI( 'deepl translate "

Hello

" --to es --tag-handling html --tag-handling-version v2' diff --git a/tests/e2e/cli-write.e2e.test.ts b/tests/e2e/cli-write.e2e.test.ts index fa02c6e..fada6d9 100644 --- a/tests/e2e/cli-write.e2e.test.ts +++ b/tests/e2e/cli-write.e2e.test.ts @@ -37,6 +37,7 @@ describe('Write Command E2E', () => { describe('Error Handling', () => { it('should accept write without --lang flag (auto-detect)', () => { + expect.assertions(1); try { execSync('deepl write "test text"', { encoding: 'utf-8', stdio: 'pipe' }); } catch (error: any) { @@ -56,6 +57,7 @@ describe('Write Command E2E', () => { }); it('should accept --lang ja without validation error', () => { + expect.assertions(1); try { execSync('deepl write "test" --lang ja', { encoding: 'utf-8', stdio: 'pipe' }); } catch (error: any) { @@ -64,6 +66,7 @@ describe('Write Command E2E', () => { }); it('should accept --lang ko without validation error', () => { + expect.assertions(1); try { execSync('deepl write "test" --lang ko', { encoding: 'utf-8', stdio: 'pipe' }); } catch (error: any) { @@ -72,6 +75,7 @@ describe('Write Command E2E', () => { }); it('should accept --lang zh without validation error', () => { + expect.assertions(1); try { execSync('deepl write "test" --lang zh', { encoding: 'utf-8', stdio: 'pipe' }); } catch (error: any) { @@ -80,6 +84,7 @@ describe('Write Command E2E', () => { }); it('should accept --lang zh-Hans without validation error', () => { + expect.assertions(1); try { execSync('deepl write "test" --lang zh-Hans', { encoding: 'utf-8', stdio: 'pipe' }); } catch (error: any) { @@ -114,6 +119,7 @@ describe('Write Command E2E', () => { fs.writeFileSync(testFile, 'Test content', 'utf-8'); // This will fail without API key, but should recognize it as a file operation + expect.assertions(1); try { execSync(`deepl write "${testFile}" --lang en-US`, { encoding: 'utf-8', stdio: 'pipe' }); } catch (error: any) { diff --git a/tests/integration/cli-auth.integration.test.ts b/tests/integration/cli-auth.integration.test.ts index 912a8c6..2e0177e 100644 --- a/tests/integration/cli-auth.integration.test.ts +++ b/tests/integration/cli-auth.integration.test.ts @@ -46,6 +46,7 @@ describe('Auth CLI Integration', () => { describe('deepl auth show', () => { it('should show "No API key set" when no key configured', () => { + expect.assertions(1); // Clear any existing key first try { runCLI('deepl auth clear'); diff --git a/tests/integration/cli-document-translation.integration.test.ts b/tests/integration/cli-document-translation.integration.test.ts index b642523..9c477bd 100644 --- a/tests/integration/cli-document-translation.integration.test.ts +++ b/tests/integration/cli-document-translation.integration.test.ts @@ -915,6 +915,7 @@ describe('Document Translation CLI Integration', () => { const testFile = path.join(testDir, 'cli-doc4.pdf'); fs.writeFileSync(testFile, Buffer.from([0x25, 0x50, 0x44, 0x46])); + expect.assertions(1); try { runCLI( `deepl translate "${testFile}" --to es --output "${testDir}/out.docx" --output-format docx`, @@ -930,6 +931,7 @@ describe('Document Translation CLI Integration', () => { const testFile = path.join(testDir, 'cli-doc5.pptx'); fs.writeFileSync(testFile, Buffer.from([0x50, 0x4b, 0x03, 0x04])); + expect.assertions(1); try { runCLI( `deepl translate "${testFile}" --to es --output "${testDir}/out.pptx" --enable-minification`, @@ -963,6 +965,7 @@ describe('Document Translation CLI Integration', () => { fs.writeFileSync(testFile, docType.content ?? ''); } + expect.assertions(1); try { runCLI( `deepl translate "${testFile}" --to es --output "${outputFile}"`, diff --git a/tests/integration/cli-glossary.integration.test.ts b/tests/integration/cli-glossary.integration.test.ts index 0fb0eaf..0b23df5 100644 --- a/tests/integration/cli-glossary.integration.test.ts +++ b/tests/integration/cli-glossary.integration.test.ts @@ -193,6 +193,7 @@ describe('Glossary CLI Integration', () => { }); it('should accept glossary name or ID', () => { + expect.assertions(1); try { // Will fail without API key but should accept argument runCLI('deepl glossary show "My Glossary"', { stdio: 'pipe' }); @@ -229,6 +230,7 @@ describe('Glossary CLI Integration', () => { }); it('should accept glossary name or ID', () => { + expect.assertions(1); try { // Will fail without API key but should accept argument runCLI('deepl glossary entries "My Glossary"', { stdio: 'pipe' }); @@ -259,6 +261,7 @@ describe('Glossary CLI Integration', () => { }); it('should accept glossary name or ID with --yes flag', () => { + expect.assertions(1); try { // Will fail without API key but should accept argument runCLI('deepl glossary delete "My Glossary" --yes', { stdio: 'pipe' }); @@ -276,6 +279,7 @@ describe('Glossary CLI Integration', () => { }); it('should accept -y short flag', () => { + expect.assertions(1); try { runCLI('deepl glossary delete "My Glossary" -y', { stdio: 'pipe' }); } catch (error: any) { @@ -335,6 +339,7 @@ describe('Glossary CLI Integration', () => { const tsvFile = path.join(testDir, 'valid-lang.tsv'); fs.writeFileSync(tsvFile, 'test\tprueba\n', 'utf-8'); + expect.assertions(2); try { // Common language pairs runCLI(`deepl glossary create "Test" en es "${tsvFile}"`, { @@ -371,6 +376,7 @@ describe('Glossary CLI Integration', () => { }); it('should not accept extraneous arguments', () => { + expect.assertions(1); try { // Will fail without API key, but should not accept extra args runCLI('deepl glossary languages extra-arg', { stdio: 'pipe' }); @@ -436,6 +442,7 @@ describe('Glossary CLI Integration', () => { }); it('should accept all required arguments', () => { + expect.assertions(1); try { // Will fail without API key but should accept arguments runCLI('deepl glossary add-entry "My Glossary" "Hello" "Hola"', { @@ -505,6 +512,7 @@ describe('Glossary CLI Integration', () => { }); it('should accept all required arguments', () => { + expect.assertions(1); try { // Will fail without API key but should accept arguments runCLI( @@ -561,6 +569,7 @@ describe('Glossary CLI Integration', () => { }); it('should accept all required arguments', () => { + expect.assertions(1); try { // Will fail without API key but should accept arguments runCLI('deepl glossary remove-entry "My Glossary" "Hello"', { @@ -616,6 +625,7 @@ describe('Glossary CLI Integration', () => { }); it('should accept all required arguments', () => { + expect.assertions(1); try { // Will fail without API key but should accept arguments runCLI('deepl glossary rename "My Glossary" "New Name"', { @@ -629,6 +639,7 @@ describe('Glossary CLI Integration', () => { }); it('should accept glossary ID as identifier', () => { + expect.assertions(1); try { // Will fail without API key but should accept ID format runCLI('deepl glossary rename "abc123-def456" "New Name"', { diff --git a/tests/integration/cli-languages.integration.test.ts b/tests/integration/cli-languages.integration.test.ts index 2da60c7..f9181c0 100644 --- a/tests/integration/cli-languages.integration.test.ts +++ b/tests/integration/cli-languages.integration.test.ts @@ -27,6 +27,7 @@ describe('Languages CLI Integration', () => { describe('deepl languages without API key (graceful degradation)', () => { it('should show registry-only output with warning when no API key', () => { + expect.assertions(2); try { runCLI('deepl auth clear'); } catch { diff --git a/tests/integration/cli-structured-file-translate.integration.test.ts b/tests/integration/cli-structured-file-translate.integration.test.ts index b5e06ee..e1887d1 100644 --- a/tests/integration/cli-structured-file-translate.integration.test.ts +++ b/tests/integration/cli-structured-file-translate.integration.test.ts @@ -46,6 +46,7 @@ describe('Structured File Translation CLI Integration', () => { const testFile = path.join(testDir, 'validation.json'); fs.writeFileSync(testFile, JSON.stringify({ key: 'test' }, null, 2)); + expect.assertions(1); try { runCLI(`deepl translate "${testFile}" --to es --output /tmp/out.json`); } catch (error: any) { @@ -56,9 +57,11 @@ describe('Structured File Translation CLI Integration', () => { }); it('should accept YAML file with --to and --output flags', () => { + expect.assertions(1); const testFile = path.join(testDir, 'validation.yaml'); fs.writeFileSync(testFile, 'key: test\n'); + expect.assertions(1); try { runCLI(`deepl translate "${testFile}" --to es --output /tmp/out.yaml`); } catch (error: any) { @@ -68,9 +71,11 @@ describe('Structured File Translation CLI Integration', () => { }); it('should accept .yml file with --to and --output flags', () => { + expect.assertions(1); const testFile = path.join(testDir, 'validation.yml'); fs.writeFileSync(testFile, 'key: test\n'); + expect.assertions(1); try { runCLI(`deepl translate "${testFile}" --to es --output /tmp/out.yml`); } catch (error: any) { diff --git a/tests/integration/cli-style-rules.integration.test.ts b/tests/integration/cli-style-rules.integration.test.ts index f1b77e2..13ddb71 100644 --- a/tests/integration/cli-style-rules.integration.test.ts +++ b/tests/integration/cli-style-rules.integration.test.ts @@ -86,6 +86,7 @@ describe('Style Rules CLI Integration', () => { describe('option flags validation', () => { it('should accept --detailed flag without error', () => { + expect.assertions(2); try { runCLI('deepl style-rules list --detailed', { stdio: 'pipe' }); } catch (error: any) { @@ -96,6 +97,7 @@ describe('Style Rules CLI Integration', () => { }); it('should accept --page flag without error', () => { + expect.assertions(2); try { runCLI('deepl style-rules list --page 2', { stdio: 'pipe' }); } catch (error: any) { @@ -106,6 +108,7 @@ describe('Style Rules CLI Integration', () => { }); it('should accept --page-size flag without error', () => { + expect.assertions(2); try { runCLI('deepl style-rules list --page-size 10', { stdio: 'pipe' }); } catch (error: any) { @@ -116,6 +119,7 @@ describe('Style Rules CLI Integration', () => { }); it('should accept --format json flag without error', () => { + expect.assertions(2); try { runCLI('deepl style-rules list --format json', { stdio: 'pipe' }); } catch (error: any) { @@ -126,6 +130,7 @@ describe('Style Rules CLI Integration', () => { }); it('should accept all flags combined', () => { + expect.assertions(2); try { runCLI( 'deepl style-rules list --detailed --page 1 --page-size 5 --format json', @@ -139,6 +144,7 @@ describe('Style Rules CLI Integration', () => { }); it('should accept create subcommand with required flags', () => { + expect.assertions(2); try { runCLI('deepl style-rules create --name Foo --language en', { stdio: 'pipe', excludeApiKey: true }); } catch (error: any) { @@ -159,6 +165,7 @@ describe('Style Rules CLI Integration', () => { }); it('should accept show subcommand with positional id', () => { + expect.assertions(2); try { runCLI('deepl style-rules show sr-1', { stdio: 'pipe', excludeApiKey: true }); } catch (error: any) { @@ -179,6 +186,7 @@ describe('Style Rules CLI Integration', () => { }); it('should accept update subcommand with --name', () => { + expect.assertions(2); try { runCLI('deepl style-rules update sr-1 --name "New"', { stdio: 'pipe', excludeApiKey: true }); } catch (error: any) { @@ -204,6 +212,7 @@ describe('Style Rules CLI Integration', () => { }); it('should accept instructions subcommand with positional style-id', () => { + expect.assertions(2); try { runCLI('deepl style-rules instructions sr-1', { stdio: 'pipe', excludeApiKey: true }); } catch (error: any) { @@ -224,6 +233,7 @@ describe('Style Rules CLI Integration', () => { }); it('should accept add-instruction with three positional args', () => { + expect.assertions(2); try { runCLI('deepl style-rules add-instruction sr-1 tone "Be formal"', { stdio: 'pipe', excludeApiKey: true }); } catch (error: any) { @@ -244,6 +254,7 @@ describe('Style Rules CLI Integration', () => { }); it('should accept update-instruction with three positional args', () => { + expect.assertions(2); try { runCLI('deepl style-rules update-instruction sr-1 tone "Be friendlier"', { stdio: 'pipe', excludeApiKey: true }); } catch (error: any) { @@ -261,6 +272,7 @@ describe('Style Rules CLI Integration', () => { }); it('should accept --source-language on add-instruction', () => { + expect.assertions(1); try { runCLI('deepl style-rules add-instruction sr-1 tone "Be formal" --source-language en', { stdio: 'pipe', excludeApiKey: true }); } catch (error: any) { diff --git a/tests/integration/deepl-client.integration.test.ts b/tests/integration/deepl-client.integration.test.ts index 786585e..2b15bc1 100644 --- a/tests/integration/deepl-client.integration.test.ts +++ b/tests/integration/deepl-client.integration.test.ts @@ -1153,6 +1153,7 @@ describe('DeepLClient Integration', () => { }); it('should capture trace ID from error response', async () => { + expect.assertions(1); const client = new DeepLClient(API_KEY); clients.push(client); diff --git a/tests/integration/sync-template-prep.integration.test.ts b/tests/integration/sync-template-prep.integration.test.ts index ec300a0..0682716 100644 --- a/tests/integration/sync-template-prep.integration.test.ts +++ b/tests/integration/sync-template-prep.integration.test.ts @@ -92,6 +92,7 @@ describe('sync-service template-pattern prep: source files read once per sync', }); it('reads each bucket source file at most once when template patterns are configured', async () => { + expect.assertions(1); const configPath = path.join(tmpDir, '.deepl-sync.yaml'); fs.writeFileSync(configPath, CONFIG_YAML, 'utf-8'); const srcPath = path.join(tmpDir, 'src', 'Features.tsx'); diff --git a/tests/unit/admin-client.test.ts b/tests/unit/admin-client.test.ts index d2e57eb..f8f0df6 100644 --- a/tests/unit/admin-client.test.ts +++ b/tests/unit/admin-client.test.ts @@ -137,6 +137,7 @@ describe('AdminClient', () => { describe('authentication errors', () => { it('should suggest an administrator key on 403 instead of re-setting the key', async () => { + expect.assertions(3); nock(baseUrl).get('/v2/admin/developer-keys').reply(403, {}); let caught: unknown; @@ -153,6 +154,7 @@ describe('AdminClient', () => { }); it('should suggest an administrator key on 401', async () => { + expect.assertions(2); nock(baseUrl).get('/v2/admin/developer-keys').reply(401, {}); let caught: unknown; diff --git a/tests/unit/deepl-client.test.ts b/tests/unit/deepl-client.test.ts index d613d9d..cc66a25 100644 --- a/tests/unit/deepl-client.test.ts +++ b/tests/unit/deepl-client.test.ts @@ -1174,6 +1174,7 @@ describe('DeepLClient', () => { }); it('should not leak credentials when proxy URL with auth fails to parse host', () => { + expect.assertions(2); process.env['HTTP_PROXY'] = 'http://user:s3cret@'; try { new DeepLClient(apiKey); diff --git a/tests/unit/glossary-client.test.ts b/tests/unit/glossary-client.test.ts index b2d0308..af5ec19 100644 --- a/tests/unit/glossary-client.test.ts +++ b/tests/unit/glossary-client.test.ts @@ -222,6 +222,7 @@ describe('GlossaryClient', () => { }); it('suffixes thrown errors with the [listGlossaries] method context', async () => { + expect.assertions(2); const axiosError = Object.assign(new Error('Request failed'), { isAxiosError: true, response: { status: 403, data: { message: 'Invalid API key' }, headers: {} }, diff --git a/tests/unit/glossary-service.test.ts b/tests/unit/glossary-service.test.ts index e154a60..4b488b1 100644 --- a/tests/unit/glossary-service.test.ts +++ b/tests/unit/glossary-service.test.ts @@ -531,6 +531,7 @@ describe('GlossaryService', () => { }); it('throws ConfigError with a UUID-disambiguation hint when two glossaries share the same name', async () => { + expect.assertions(3); mockDeepLClient.listGlossaries.mockResolvedValue([ { ...cleanGlossary, glossary_id: 'first-id', name: 'shared' }, { ...cleanGlossary, glossary_id: 'second-id', name: 'shared' }, @@ -550,6 +551,7 @@ describe('GlossaryService', () => { }); it('does not echo raw control chars from the caller input into the error message', async () => { + expect.assertions(3); mockDeepLClient.listGlossaries.mockResolvedValue([]); const dirty = 'bad\x07name'; diff --git a/tests/unit/register-commands-group1.test.ts b/tests/unit/register-commands-group1.test.ts index 81ce9cf..7c756d8 100644 --- a/tests/unit/register-commands-group1.test.ts +++ b/tests/unit/register-commands-group1.test.ts @@ -609,6 +609,7 @@ describe('registerCompletion', () => { }); it('should mention supported shells in error for unsupported shell', async () => { + expect.assertions(3); await loadAndRegister(); try { diff --git a/tests/unit/sync/sync-command.test.ts b/tests/unit/sync/sync-command.test.ts index 6d703f0..c22b858 100644 --- a/tests/unit/sync/sync-command.test.ts +++ b/tests/unit/sync/sync-command.test.ts @@ -164,6 +164,7 @@ describe('SyncCommand', () => { }); it('should output valid JSON to stdout when format is json', async () => { + expect.assertions(4); const result = makeResult(); const mockService = createMockSyncService(result); const command = new SyncCommand(mockService); diff --git a/tests/unit/sync/sync-config.test.ts b/tests/unit/sync/sync-config.test.ts index 6c55cc8..b1b8cf2 100644 --- a/tests/unit/sync/sync-config.test.ts +++ b/tests/unit/sync/sync-config.test.ts @@ -1505,6 +1505,7 @@ ${extra} describe('error message terminal sanitization', () => { it('replaces control chars in unknown-field key before rendering in ConfigError', () => { + expect.assertions(4); const evil = 'evil\x1b[31mkey\x00'; try { validateSyncConfig({ @@ -1525,6 +1526,7 @@ ${extra} }); it('replaces zero-width codepoints in unknown-field key before rendering in ConfigError', () => { + expect.assertions(2); const evil = 'foo\u200bbar'; try { validateSyncConfig({ diff --git a/tests/unit/sync/sync-init-validate.test.ts b/tests/unit/sync/sync-init-validate.test.ts index e441ed5..970480e 100644 --- a/tests/unit/sync/sync-init-validate.test.ts +++ b/tests/unit/sync/sync-init-validate.test.ts @@ -17,6 +17,7 @@ describe('sync-init-validate', () => { }); it('rejects source locale appearing in target-locales (case-insensitive)', () => { + expect.assertions(4); expect(() => validateSyncInitFlags({ ...base, targetLocales: 'de,EN,fr' })) .toThrow(ValidationError); try { @@ -30,6 +31,7 @@ describe('sync-init-validate', () => { }); it('rejects duplicate target-locales (case-insensitive)', () => { + expect.assertions(3); expect(() => validateSyncInitFlags({ ...base, targetLocales: 'de,fr,DE' })) .toThrow(ValidationError); try { diff --git a/tests/unit/translation-client.test.ts b/tests/unit/translation-client.test.ts index 64798db..3952e2e 100644 --- a/tests/unit/translation-client.test.ts +++ b/tests/unit/translation-client.test.ts @@ -361,6 +361,7 @@ describe('TranslationClient', () => { describe('listTranslationMemories() error context', () => { it('suffixes thrown errors with the [listTranslationMemories] method context', async () => { + expect.assertions(2); const axiosError = Object.assign(new Error('Request failed'), { isAxiosError: true, response: { status: 403, data: { message: 'Invalid API key' }, headers: {} }, diff --git a/tests/unit/translation-memory.test.ts b/tests/unit/translation-memory.test.ts index 97893bd..19ed5f4 100644 --- a/tests/unit/translation-memory.test.ts +++ b/tests/unit/translation-memory.test.ts @@ -236,6 +236,7 @@ describe('resolveTranslationMemoryId', () => { describe('name-not-found', () => { it('throws ConfigError with the verbatim suggestion', async () => { + expect.assertions(4); const client = makeClient([]); const cache = new Map(); @@ -256,6 +257,7 @@ describe('resolveTranslationMemoryId', () => { }); it('sanitizes control chars and clamps to 80 chars in the error message', async () => { + expect.assertions(2); const client = makeClient([]); const cache = new Map(); const dirty = `bad\x07${'x'.repeat(100)}`; diff --git a/tests/unit/utils/glob-prefix.test.ts b/tests/unit/utils/glob-prefix.test.ts index 74d5482..c3e1555 100644 --- a/tests/unit/utils/glob-prefix.test.ts +++ b/tests/unit/utils/glob-prefix.test.ts @@ -82,6 +82,7 @@ describe('extractGlobLiteralPrefix', () => { }); it('includes the offending pattern in the error message', () => { + expect.assertions(2); let caught: unknown = null; try { extractGlobLiteralPrefix('{..,src}/**'); diff --git a/tests/unit/watch-command.test.ts b/tests/unit/watch-command.test.ts index 180621f..a537507 100644 --- a/tests/unit/watch-command.test.ts +++ b/tests/unit/watch-command.test.ts @@ -131,6 +131,7 @@ describe('WatchCommand', () => { }); it('should parse multiple target languages', async () => { + expect.assertions(2); (fs.existsSync as jest.Mock).mockReturnValue(true); (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); @@ -154,6 +155,7 @@ describe('WatchCommand', () => { }); it('should use custom output directory when provided', async () => { + expect.assertions(1); (fs.existsSync as jest.Mock).mockReturnValue(true); (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); @@ -176,6 +178,7 @@ describe('WatchCommand', () => { }); it('should create translations subdirectory for directory input', async () => { + expect.assertions(1); (fs.existsSync as jest.Mock).mockReturnValueOnce(true).mockReturnValueOnce(false); (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true }); mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); @@ -194,6 +197,7 @@ describe('WatchCommand', () => { }); it('should use same directory for file input when no output specified', async () => { + expect.assertions(1); (fs.existsSync as jest.Mock).mockReturnValue(true); (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); @@ -215,6 +219,7 @@ describe('WatchCommand', () => { }); it('should pass debounce option to WatchService', async () => { + expect.assertions(1); (fs.existsSync as jest.Mock).mockReturnValue(true); (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); @@ -237,6 +242,7 @@ describe('WatchCommand', () => { }); it('should pass pattern option to WatchService', async () => { + expect.assertions(1); (fs.existsSync as jest.Mock).mockReturnValue(true); (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); @@ -259,6 +265,7 @@ describe('WatchCommand', () => { }); it('should pass translation options to watch service', async () => { + expect.assertions(1); (fs.existsSync as jest.Mock).mockReturnValue(true); (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); @@ -286,6 +293,7 @@ describe('WatchCommand', () => { }); it('should register onChange callback', async () => { + expect.assertions(2); (fs.existsSync as jest.Mock).mockReturnValue(true); (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); @@ -310,6 +318,7 @@ describe('WatchCommand', () => { }); it('should register onTranslate callback for multiple languages', async () => { + expect.assertions(2); (fs.existsSync as jest.Mock).mockReturnValue(true); (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); @@ -335,6 +344,7 @@ describe('WatchCommand', () => { }); it('should register onTranslate callback for single language', async () => { + expect.assertions(1); (fs.existsSync as jest.Mock).mockReturnValue(true); (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); @@ -360,6 +370,7 @@ describe('WatchCommand', () => { }); it('should register onError callback', async () => { + expect.assertions(2); (fs.existsSync as jest.Mock).mockReturnValue(true); (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); @@ -418,6 +429,7 @@ describe('WatchCommand', () => { }, 1000); it('should use current directory when file is in root', async () => { + expect.assertions(1); (fs.existsSync as jest.Mock).mockReturnValue(true); (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); @@ -457,6 +469,7 @@ describe('WatchCommand', () => { }); it('should auto-commit translations when autoCommit is enabled for multiple languages', async () => { + expect.assertions(1); mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); // Mock exec to simulate git commands @@ -493,6 +506,7 @@ describe('WatchCommand', () => { }); it('should auto-commit translations when autoCommit is enabled for single language', async () => { + expect.assertions(1); mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); try { @@ -517,6 +531,7 @@ describe('WatchCommand', () => { }); it('should handle auto-commit when not in git repository', async () => { + expect.assertions(1); mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); try { @@ -542,6 +557,7 @@ describe('WatchCommand', () => { }); it('should handle auto-commit with no output files', async () => { + expect.assertions(1); mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); try { @@ -566,6 +582,7 @@ describe('WatchCommand', () => { }); it('should handle auto-commit errors gracefully', async () => { + expect.assertions(1); mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); try { @@ -600,6 +617,7 @@ describe('WatchCommand', () => { }); it('should call getStagedFiles and pass result to WatchService when gitStaged is true', async () => { + expect.assertions(2); const stagedSet = new Set(['/abs/file1.txt']); jest.spyOn(watchCommand, 'getStagedFiles').mockResolvedValue(stagedSet); mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); @@ -651,6 +669,7 @@ describe('WatchCommand', () => { }); it('should not call getStagedFiles when gitStaged is not set', async () => { + expect.assertions(1); jest.spyOn(watchCommand, 'getStagedFiles'); mockWatchService.watch.mockImplementation(() => { throw new Error('Test complete'); }); From 772e6c1d8d09a5b80b650585a25dde9b9bf4972d Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Thu, 30 Jul 2026 00:25:03 -0400 Subject: [PATCH 11/13] docs(changelog): record NO_PROXY support and the test-integrity changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad9d21e..439a972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **http**: `NO_PROXY` / `no_proxy` are honoured, with the standard semantics — `*` for everything, a leading dot or `*.` for subdomains, and an optional `host:port` that must agree. A corporate `HTTPS_PROXY` was previously applied to every request, including one aimed at localhost. - **auth**: `deepl auth set-key --no-verify` stores a key without validating it against the API. Validation ran before persisting, so on a network without proxy configuration both documented setup paths — `auth set-key` and `init` — failed and discarded the key; an unreachable API now also names `DEEPL_API_KEY` as the zero-network alternative. - **ci**: `npm run check-deps` fails the build when a package imported by `src/` is missing from `dependencies`, including one declared only under `devDependencies`. It runs in CI and in the publish job, and matches package names as quoted strings so indirect loads such as `requireModule('php-parser')` count as references. - **cli**: `t` and `w` command aliases for `translate` and `write` ([#12](https://github.com/DeepL/deepl-cli/issues/12)). The aliases appear in `--help` output and in bash/zsh/fish shell completions. `w` is deliberately assigned to `write` rather than `watch` — write is a primary API feature, watch a workflow helper. @@ -20,7 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **docs**: All install instructions now target `@deepl/cli` (10 occurrences across `docs/SYNC.md`, four example scripts, `examples/README.md`, and the git-hook template in `src/services/git-hooks.ts`). The README installation section documents three install paths — Homebrew (`brew install deepl/tap/deepl`, which manages Node itself), npm (`npm install -g @deepl/cli`), and from source — with an explicit Node.js 24 prerequisite, replacing the `better-sqlite3` native-compilation caveat (Xcode CLT / python3-make-gcc), which no longer applies. The `TROUBLESHOOTING.md` `NODE_MODULE_VERSION` / `npm rebuild better-sqlite3` entry is replaced by accurate guidance for the one remaining cache-degradation cause (running on Node < 24). Six stale `DeepLcom` GitHub URLs now point at the `DeepL` org directly. `CONTRIBUTING.md` states the Node 24 development prerequisite, and `SECURITY.md`'s supported-versions table reflects that only 2.x is a published, supported line. +- **tests**: Tests that assert only inside a `catch` block now declare their assertion count, so they fail instead of passing with zero assertions when the command under test unexpectedly succeeds — the failure they exist to detect was the one they could not see. Sites that already assert on the success path are unchanged. `npm test` also refuses to run against a **stale** `dist/`, not just a missing one: the suites execute the built CLI, so a stale build reported results that did not describe the current source. A new suite checks every documented `deepl …` invocation in the README and docs against the CLI's real surface, so a command or flag that drifts out of existence fails CI rather than waiting for a reader to find it. +- **docs**: The README leads with the npm install path and marks Homebrew as pending until the tap exists. Install instructions target `@deepl/cli` (10 occurrences across `docs/SYNC.md`, four example scripts, `examples/README.md`, and the git-hook template in `src/services/git-hooks.ts`). The README installation section documents three install paths — npm (`npm install -g @deepl/cli`), from source, and Homebrew (`brew install deepl/tap/deepl`, marked pending until the tap ships) — with an explicit Node.js 24 prerequisite, replacing the `better-sqlite3` native-compilation caveat (Xcode CLT / python3-make-gcc), which no longer applies. The `TROUBLESHOOTING.md` `NODE_MODULE_VERSION` / `npm rebuild better-sqlite3` entry is replaced by accurate guidance for the one remaining cache-degradation cause (running on Node < 24). Six stale `DeepLcom` GitHub URLs now point at the `DeepL` org directly. `CONTRIBUTING.md` states the Node 24 development prerequisite, and `SECURITY.md`'s supported-versions table reflects that only 2.x is a published, supported line. - **tests**: Removed the global manual mocks for `p-limit` and `fast-glob` (`tests/__mocks__/`). Because a manual mock for a node module is auto-applied to every suite, and `resetMocks: true` strips its implementation, `pLimit(n)` resolved to `undefined` and `fg(...)` resolved to `undefined` in all 236 suites — so no test exercised a real concurrency limit or a real glob walk, and two concurrency defects reached a release candidate undetected. The suites that need these mocked declare them explicitly with their own implementations, so nothing depended on the global versions. Added `tests/unit/concurrency-limiting.test.ts`, which asserts that `p-limit` rejects a non-positive concurrency, that peak overlap never exceeds the limit, and that `fast-glob` returns real paths — it fails if either global mock is reintroduced. - **tests**: Suites that shell out to the bare `deepl` command now run this tree's built CLI via a PATH shim installed in jest `globalSetup`, instead of whatever `deepl` happens to be globally installed. 23 suites (392 tests) previously required a global install — absent one they all failed with `command not found`, and present one they silently tested the installed version rather than the working tree. The shim lives in the real environment before jest workers spawn (a `setupFilesAfterEnv` hook cannot do this: test code sees a copied `process.env` that child processes never inherit). CI's now-redundant `npm link` step is removed, and a new unit suite pins that `deepl` resolves to the shim and reports the tree's version. - **tests**: `npm test` now fails fast with an actionable message when `dist/cli/index.js` is missing. `dist/` is gitignored and `npm test` does not build, yet all three test tiers execute the built CLI, so a fresh checkout previously failed hundreds of tests with errors that never mentioned the missing build. From b8502becea2e22141f27572ee2aa1fadfa5a24b1 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Thu, 30 Jul 2026 00:42:52 -0400 Subject: [PATCH 12/13] fix(sync): refuse consistently when retrying auto-commit after a refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refused auto-commit still writes its translations to disk, so the retry finds nothing to translate. The preflight was gated on this run having written files, so the retry skipped the checks entirely and exited 0 — no commit made, the translation still uncommitted, the tree still dirty, and no error shown. Reproduced directly: the second run returned success with fileResults empty and HEAD unmoved. The repo-state checks now run whenever --auto-commit is requested; only staging and committing are skipped when there is nothing to commit. Two existing tests covered this path and could not see it: their assertions sat in a catch block the retry never entered. Declaring the assertion counts made both fail, which also exposed an assertion that could never have matched — it looked for "detached HEAD" in a message reading "HEAD is detached". Counts corrected, the regex fixed, and a test added for the retry itself. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CHANGELOG.md | 1 + src/cli/commands/sync-command.ts | 11 ++++++-- .../sync-auto-commit.integration.test.ts | 28 ++++++++++++++++++- tests/unit/sync/sync-command.test.ts | 13 +++++++-- 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 439a972..f721df4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **sync**: Re-running `deepl sync --auto-commit` after a refusal no longer reports success without committing. A refused run still writes its translations to disk, so the retry found nothing to translate; the preflight was skipped in that case and the command exited 0 with no commit made, the translation still uncommitted and the tree still dirty. The repo-state checks now run whenever `--auto-commit` is requested, so the refusal is reported identically on every attempt. Staging and committing are still skipped when there is nothing to commit. Two tests covered this path but could not see the defect: their assertions ran only inside a `catch` the retry never entered, which also hid an assertion that could never have matched (it looked for "detached HEAD" against a message reading "HEAD is detached"). - **write**: `deepl write` now runs from a published install. `diff` is imported at the top of the write command but was declared only under `devDependencies`, which consumers do not install, so every command that loaded the module failed with `Cannot find package 'diff'` before producing output. `--help` did not surface it because the module loads lazily. - **init/write/sync**: `@inquirer/prompts` is declared as a dependency. It is imported at runtime by `init`, `write --interactive` and `sync init` while only the unused `inquirer` was declared, so it resolved through npm's hoisting: under a strict layout (pnpm, `--install-strategy=nested`) those commands failed with `ERR_MODULE_NOT_FOUND`, and under npm the prompt that reads the API key bound to whatever major another dependent happened to hoist. - **init**: `deepl init` with stdin at end-of-file now exits 6 with the documented non-interactive message, instead of starting to prompt and then exiting 1 with a Node `unsettled top-level await` warning. Reached by `docker run` without `-it`, CI, and piped invocations. The command checked only `--no-input`, where the sibling guard in `write --interactive` also checks whether stdin is a terminal; the existing test covered only the `--no-input` variant, which already worked. diff --git a/src/cli/commands/sync-command.ts b/src/cli/commands/sync-command.ts index 08aa51b..19d5f2c 100644 --- a/src/cli/commands/sync-command.ts +++ b/src/cli/commands/sync-command.ts @@ -270,7 +270,7 @@ export class SyncCommand { process.exitCode = ExitCode.PartialFailure; } - if (options.autoCommit && result.success && !result.dryRun && !result.driftDetected && result.fileResults.length > 0) { + if (options.autoCommit && result.success && !result.dryRun && !result.driftDetected) { await this.autoCommitTranslations(result, config); } @@ -445,11 +445,14 @@ export class SyncCommand { .filter(r => r.written) .map(r => r.file); - if (writtenFiles.length === 0) return; - // Preflight: refuse to auto-commit in unsafe repo states. We want to avoid // bundling unrelated work into the chore(i18n) commit and to avoid // committing onto the wrong ref. + // + // These run even when this sync wrote nothing. A refused run still leaves + // its translations on disk, so the retry finds nothing to translate — and + // skipping the checks there reported success while no commit had been made + // and the tree was still dirty. const expectedStaged = new Set(writtenFiles); if (result.lockUpdated) expectedStaged.add(LOCK_FILE_NAME); @@ -509,6 +512,8 @@ export class SyncCommand { ); } + if (writtenFiles.length === 0) return; + const filesToStage: string[] = [...writtenFiles]; if (result.lockUpdated && fsMod.existsSync(pathMod.join(cwd, LOCK_FILE_NAME))) { filesToStage.push(LOCK_FILE_NAME); diff --git a/tests/integration/sync-auto-commit.integration.test.ts b/tests/integration/sync-auto-commit.integration.test.ts index 5d3d380..8581ea1 100644 --- a/tests/integration/sync-auto-commit.integration.test.ts +++ b/tests/integration/sync-auto-commit.integration.test.ts @@ -156,6 +156,7 @@ describeIfGit('SyncCommand auto-commit preflight', () => { }); it('dirty tree with unrelated changes: auto-commit refused with ValidationError naming offending files', async () => { + expect.assertions(5); seedRepo(tmpDir); fs.writeFileSync(path.join(tmpDir, 'README.md'), '# changed\n', 'utf-8'); fs.writeFileSync(path.join(tmpDir, 'src.ts'), 'export {};\n', 'utf-8'); @@ -181,6 +182,7 @@ describeIfGit('SyncCommand auto-commit preflight', () => { }); it('mid-rebase state: auto-commit refused', async () => { + expect.assertions(3); seedRepo(tmpDir); // Simulate a mid-rebase repo by writing the rebase-merge directory. const rebaseDir = path.join(tmpDir, '.git', 'rebase-merge'); @@ -199,6 +201,7 @@ describeIfGit('SyncCommand auto-commit preflight', () => { }); it('detached HEAD: auto-commit refused', async () => { + expect.assertions(3); seedRepo(tmpDir); // Add a second commit then detach at the first. fs.writeFileSync(path.join(tmpDir, 'x.txt'), 'x\n', 'utf-8'); @@ -214,10 +217,33 @@ describeIfGit('SyncCommand auto-commit preflight', () => { await command.run({ autoCommit: true }); } catch (err) { expect((err as Error).message).toMatch(/Refusing to auto-commit/i); - expect((err as Error).message).toMatch(/detached HEAD/i); + expect((err as Error).message).toMatch(/HEAD is detached/i); } }); + // A refused run leaves its translations on disk, so the retry has nothing new + // to translate. The preflight used to be skipped in that case, and the retry + // reported success with no commit made and the tree still dirty. + it('retry after a refusal refuses again instead of reporting success', async () => { + expect.assertions(4); + seedRepo(tmpDir); + fs.writeFileSync(path.join(tmpDir, 'README.md'), '# changed\n', 'utf-8'); + const scope = seedTranslateMock(); + scope.persist(); + + const command = new SyncCommand(syncService); + await expect(command.run({ autoCommit: true })).rejects.toThrow(ValidationError); + + // The first run wrote the translation before refusing, so nothing is left + // to translate on the retry. + expect(fs.existsSync(path.join(tmpDir, 'locales', 'de.json'))).toBe(true); + + await expect(command.run({ autoCommit: true })).rejects.toThrow(/Refusing to auto-commit/i); + + nock.cleanAll(); + expect(git(tmpDir, ['log', '-1', '--pretty=%s']).trim()).toBe('initial'); + }); + it('clean repo, no lockfile update: auto-commit does not fail staging a nonexistent lockfile', async () => { seedRepo(tmpDir); const scope = seedTranslateMock(); diff --git a/tests/unit/sync/sync-command.test.ts b/tests/unit/sync/sync-command.test.ts index c22b858..c7568f9 100644 --- a/tests/unit/sync/sync-command.test.ts +++ b/tests/unit/sync/sync-command.test.ts @@ -758,14 +758,23 @@ describe('SyncCommand', () => { expect(commitCalls).toHaveLength(0); }); - it('should not call git when fileResults is empty', async () => { + // The preflight runs even with nothing to commit, so that a retry after a + // refusal reports the refusal again rather than succeeding silently. Only + // staging and committing are skipped. + it('should run the preflight but not commit when fileResults is empty', async () => { const result = makeResult({ fileResults: [] }); const mockService = createMockSyncService(result); const command = new SyncCommand(mockService); await command.run({ autoCommit: true }); - expect(mockExecFile).not.toHaveBeenCalled(); + const args = mockExecFile.mock.calls + .filter((c: unknown[]) => Array.isArray(c[1])) + .map((c: unknown[]) => (c[1] as string[]).join(' ')); + + expect(args).toContain('symbolic-ref -q HEAD'); + expect(args.some((a) => a.startsWith('add '))).toBe(false); + expect(args.some((a) => a.startsWith('commit '))).toBe(false); }); it('should not call git when dryRun is true', async () => { From d180349248e0124c479254d3597ea987831a3179 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Thu, 30 Jul 2026 08:29:20 -0400 Subject: [PATCH 13/13] ci: stop attempting to publish to npm from GitHub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm publishing cannot be triggered from GitHub for this repository. Releases are published from GitLab, which is the source of truth and mirrors to GitHub — the same arrangement the other DeepL client libraries use, where deepl-node publishes from a .gitlab-ci.yml job and has its GitHub publish step commented out. Removes the publish job, along with the npm-publish environment reference, id-token permission, NPM_TOKEN wiring and --provenance. Removes the homebrew formula-bump job too: it declared `needs: publish`, so leaving it would make the workflow invalid, and its bump logic is recorded on the tap issue for whoever builds the GitLab pipeline. The tag-versus-package.json guard moves into the release job rather than being deleted with its former host. A tag can be pushed at any commit, and without the check a mislabelled tag would mint a Release whose title disagrees with the version it contains. What remains on a tag push is the GitHub Release and nothing else. The three changelog entries describing GitHub-side publishing, provenance and the required-reviewer gate are reworded, since they would otherwise ship as false claims in the 2.0.0 notes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/release.yml | 96 ++++------------------------------- CHANGELOG.md | 6 +-- 2 files changed, 14 insertions(+), 88 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d0e03c..72f5c80 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,41 +1,27 @@ name: Release +# Publishing to npm does not happen here. Releases are published from GitLab, +# which is the source of truth and mirrors to GitHub; this workflow only +# records the GitHub Release for a tag. Do not add an `npm publish` step or an +# NPM_TOKEN secret to this repository — see the release beads for the +# GitLab-side pipeline. on: push: tags: - 'v*' jobs: - # The publish and release jobs are deliberately independent: a failure - # in one must not suppress the other. - publish: + release: runs-on: ubuntu-latest - # Publishing requires manual approval via the environment's required - # reviewers, so a pushed tag alone cannot release to npm. - environment: npm-publish - permissions: - contents: read - id-token: write + contents: write steps: - # Actions in this job are pinned to commit SHAs because it is the only - # job holding NPM_TOKEN; a moved tag here would run unreviewed code - # with publish credentials. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: '24' - registry-url: https://registry.npmjs.org - cache: npm - - - run: npm ci + - uses: actions/checkout@v7 - # npm rejects a duplicate version, but this package's first publish has - # no prior version to collide with: without this check, tagging v2.0.0 - # before the version bump publishes the stale version under a v2.0.0 - # provenance attestation, and npm versions cannot be reused. + # A tag can be pushed at any commit, so a mislabelled tag would otherwise + # mint a Release whose title disagrees with the version it contains. - name: Verify tag matches package version run: | tag_version="${GITHUB_REF_NAME#v}" @@ -45,31 +31,6 @@ jobs: exit 1 fi - # A tag can point at any commit, including one CI never ran, so the - # publish job re-verifies rather than trusting the branch. No DeepL API - # key is available to this job: the suite must stay hermetic. - - run: npm run lint - - run: npm run type-check - - run: npm run check-deps - - run: npm run build - - run: npm test - - # First publish authenticates with a granular NPM_TOKEN scoped to - # @deepl/cli. Switch to OIDC trusted publishing once the package - # exists on npmjs.com, then revoke the token (tracked in beads). - - run: npm publish --provenance --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - release: - runs-on: ubuntu-latest - - permissions: - contents: write - - steps: - - uses: actions/checkout@v7 - # Release notes come from the tag's CHANGELOG section; releases are # immutable once published, but notes stay editable afterwards. - name: Extract changelog section @@ -86,8 +47,7 @@ jobs: fi # Skipped when the release already exists so that re-running the - # workflow after a publish failure does not fail on work that - # already succeeded. + # workflow does not fail on work that already succeeded. - name: Create GitHub Release env: GH_TOKEN: ${{ github.token }} @@ -103,37 +63,3 @@ jobs: gh release create "$GITHUB_REF_NAME" --verify-tag \ --title "$GITHUB_REF_NAME" --generate-notes fi - - # Gated until DeepL/homebrew-tap exists and a HOMEBREW_TAP_TOKEN secret - # (a fine-grained PAT with contents + pull-requests write on that repo) - # is configured — see the Homebrew tap issue. github.token cannot push - # cross-repo. To enable: change `if:` to `if: ${{ !cancelled() }}`. - homebrew: - if: false - needs: publish - runs-on: ubuntu-latest - - permissions: {} - - steps: - - name: Bump formula version and sha256 - env: - GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - run: | - version="${GITHUB_REF_NAME#v}" - url="https://registry.npmjs.org/@deepl/cli/-/cli-${version}.tgz" - curl -fsSL -o cli.tgz "$url" - sha256="$(sha256sum cli.tgz | cut -d' ' -f1)" - - gh repo clone DeepL/homebrew-tap tap - cd tap - branch="bump-deepl-${version}" - git checkout -b "$branch" - sed -i -E "s|^( url \").*(\")$|\1${url}\2|" Formula/deepl.rb - sed -i -E "s|^( sha256 \").*(\")$|\1${sha256}\2|" Formula/deepl.rb - git config user.name 'github-actions[bot]' - git config user.email 'github-actions[bot]@users.noreply.github.com' - git commit -am "deepl ${version}" - git push -u origin "$branch" - gh pr create --title "deepl ${version}" \ - --body "Automated formula bump for @deepl/cli ${version}. sha256: \`${sha256}\`" diff --git a/CHANGELOG.md b/CHANGELOG.md index f721df4..0989b31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **ci**: `npm run check-deps` fails the build when a package imported by `src/` is missing from `dependencies`, including one declared only under `devDependencies`. It runs in CI and in the publish job, and matches package names as quoted strings so indirect loads such as `requireModule('php-parser')` count as references. - **cli**: `t` and `w` command aliases for `translate` and `write` ([#12](https://github.com/DeepL/deepl-cli/issues/12)). The aliases appear in `--help` output and in bash/zsh/fish shell completions. `w` is deliberately assigned to `write` rather than `watch` — write is a primary API feature, watch a workflow helper. -- **ci**: Pushing a `v*` tag now runs the full release pipeline. The npm publish job is enabled (it was hard-disabled with `if: false` since its introduction, which is why the repo has 17 tags and zero GitHub Releases); it authenticates with a granular `NPM_TOKEN` for the first publish and keeps `--provenance` attestation. A new, deliberately independent `release` job creates a GitHub Release from the tag, with notes extracted from the version's CHANGELOG section (falling back to generated notes if the section is missing) — a publish failure cannot suppress the Release, and vice versa. A `homebrew` formula-bump job (version + sha256 PR against `DeepL/homebrew-tap`) ships gated with `if: false` until the tap repo and its cross-repo token exist. +- **ci**: Pushing a `v*` tag now creates a GitHub Release, with notes extracted from that version's CHANGELOG section and generated notes as a fallback when the section is missing. This is why the repo has 17 tags and zero Releases. The workflow does **not** publish to npm: releases are published from GitLab, which is the source of truth and mirrors to GitHub, matching the other DeepL client libraries. The tag is checked against `package.json` first, so a mislabelled tag cannot mint a Release whose title disagrees with the version it contains. - **cli**: Global `--timeout ` and `--max-retries ` options override the HTTP transport defaults (30000 ms, 3 retries) for a single invocation. Neither was previously configurable from the CLI. - **translate**: `--format json` output now includes the documented `cached` boolean, so scripts can distinguish cache hits from fresh API calls. - **cli**: Running under Node.js < 24 now fails fast with a clear one-line error (exit 6) instead of surfacing a raw `node:sqlite` ExperimentalWarning or crashing later. @@ -133,7 +133,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **config**: `ConfigService.save()` writes through an unpredictably named temp file created with an exclusive flag, instead of a fixed `config.json.tmp`. A symlink planted at the predictable path redirected the config — which holds the API key in plaintext — to a path of the planter's choosing, and the subsequent rename left `config.json` as that symlink, so every later write followed it too. The mode is also applied with `chmod` after creation, which the umask cannot widen. - **tests**: The test suite no longer inherits real credentials or the real config directory. Suites that spawn the bare `deepl` command cannot be intercepted by nock, so they reached the live DeepL API with whatever key was exported and read and wrote the developer's cache database; cached responses matching this suite's fixtures were recovered from a real cache, confirming it had happened. `globalSetup` now clears `DEEPL_API_KEY`, `TMS_API_KEY` and `TMS_TOKEN` and points `DEEPL_CONFIG_DIR` at a temporary directory before workers fork. - **formats**: The prototype-pollution guard in the JSON parser is now pinned by tests. Replacing `Object.defineProperty` with plain assignment previously left the whole suite green: on a fresh object `obj['__proto__'] = value` retargets that object's prototype rather than `Object.prototype`, so the translation was silently dropped while the negative pollution assertion still passed. -- **ci**: The publish job pins its actions to commit SHAs, since it is the only job holding `NPM_TOKEN`, and refuses to publish when the pushed tag does not match `package.json`. npm's duplicate-version protection cannot catch that mistake on a first publish, which would otherwise ship the wrong version under a provenance attestation naming the tag. The job also now runs lint, type-check, the dependency check and the full suite, because a tag can point at a commit CI never ran. +- **ci**: The release workflow refuses to create a GitHub Release when the pushed tag does not match `package.json`. A tag can be pushed at any commit, so without the check a mislabelled tag would mint a Release whose title disagrees with the version it contains. - **translate/sync**: Placeholder restoration no longer hangs the CLI with unbounded memory growth. `restorePlaceholders` looped `while (restored.includes(placeholder))`, replacing one occurrence per pass — so when the preserved original itself contained the placeholder token, every pass re-inserted it and the guard never went false. Measured before the fix: the input `{__VAR_0__}` grew from 9 to 400,009 bytes across 200,000 iterations without converging, and the real function had to be killed. It needed no attacker and no network: `preserveVariables`' pattern matches `{__VAR_0__}` (underscores and digits are in its character class), variable preservation runs unconditionally, and restoration also runs on **cached** results — so a locale value of that shape hung the process with no API call. Each placeholder is now restored in a single pass, using the function form of the replacement so `$&`/`$1` inside a preserved value stay literal. - **sync**: Bucket `include` globs can no longer escape the project root, and `--dry-run` no longer modifies the working tree. `include` entries were validated only as non-empty strings, while `target_path_pattern` a few lines later already rejected `..`. The unvalidated glob's literal prefix was resolved and handed to the stale-`.bak` sweep, which recursed with **no containment check** — deleting every old `*.bak` it found and *re-creating* any file whose `.bak` existed while the live file was missing or empty. Verified: `include: "../../../../../../**/*.json"` produced a sweep root of `/var`, an out-of-root `.bak` was deleted and its sibling resurrected with the backup's contents. Two things made it worse: the sweep was gated only on watch runs, so it ran under `--dry-run` — the flag a cautious user reaches for to avoid side effects — and its errors were swallowed entirely, so it was silent. `include` entries are now rejected at config load for traversal segments and absolute paths (the check the `sync init` wizard already applied, which simply did not exist on the load path), the sweep independently refuses any root outside the project and logs the attempt, the sweep is skipped under `--dry-run`, and its failures are reported instead of discarded. - **deps**: Resolved four production-dependency advisories flagged by `npm audit --omit=dev` via lockfile-only transitive bumps (no `package.json` ranges changed): `brace-expansion` 5.0.5 → 5.0.6 (GHSA-jxxr-4gwj-5jf2, ReDoS), `form-data` → 4.0.6 (GHSA-hmw2-7cc7-3qxx, CRLF injection), and `ws` 8.20.0 → 8.21.0 (GHSA-58qx-3vcg-4xpx and GHSA-96hv-2xvq-fx4p, uninitialized-memory disclosure and memory-exhaustion DoS). Production `npm audit` is back to zero vulnerabilities, unblocking the CI audit gate. @@ -148,7 +148,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **glossary**: Server-supplied glossary names are sanitized before terminal display, blocking ANSI escape injection via glossaries shared within a team account. Style-rule names were already sanitized; the two sites now match. - **config**: `deepl config set` rejects `__proto__`, `constructor`, and `prototype` path segments and resolves keys with `Object.hasOwn`, so crafted paths can no longer pollute `Object.prototype`. - **batch**: `translate --pattern` values containing `..` can no longer write translated output outside `--output-dir` (the default output branch now enforces the same containment as `--output-pattern`), and directory batch translation no longer follows symlinks out of the input directory. -- **ci**: The npm publish job is gated behind the `npm-publish` GitHub environment so a pushed `v*` tag alone cannot release without a required reviewer's approval, and `ci.yml`/`security.yml` explicitly request `contents: read` instead of inheriting the repository default token permissions. +- **ci**: `ci.yml` and `security.yml` explicitly request `contents: read` instead of inheriting the repository default token permissions. ## [1.2.0] - 2026-04-25 ### Added