Skip to content

Publish v1.2.0: Write API extension + Style Rules CRUD + table-format parity#45

Open
sjsyrek wants to merge 24 commits intorelease/v1.1.0-publishfrom
release/v1.2.0-publish
Open

Publish v1.2.0: Write API extension + Style Rules CRUD + table-format parity#45
sjsyrek wants to merge 24 commits intorelease/v1.1.0-publishfrom
release/v1.2.0-publish

Conversation

@sjsyrek
Copy link
Copy Markdown
Contributor

@sjsyrek sjsyrek commented Apr 25, 2026

Stacked on top of #44 (release/v1.1.0-publish). Once #44 merges, GitHub will rebase this PR's base onto main and the diff will narrow to just the v1.2.0 work.

Summary

  • Write API extension: Japanese (ja), Korean (ko), and Simplified Chinese (zh, zh-Hans) target languages; --tone and --style extended to Spanish, Italian, French, and Portuguese variants; 4xx responses with --style/--tone set carry an actionable recovery hint pointing at docs/API.md.
  • Style Rules CRUD: deepl style-rules create|show|update|delete alongside the existing list. --rules takes a JSON object of category → settings (e.g. '{"punctuation":{"quotation_mark":"use_guillemets"}}') matching the DeepL API's two-level rule shape.
  • Custom Instructions: instructions <id> (list) plus add-instruction <style-id> <label> <prompt>, update-instruction <style-id> <label> <prompt>, remove-instruction <style-id> <label>. Per-instruction operations resolve label → server-assigned UUID before hitting the URL path.
  • Table format parity: --format table now actually renders a cli-table3 table on languages, cache stats, usage, style-rules list, and style-rules instructions. Previously the flag was advertised in --help on languages/cache/usage but the action handlers only branched on 'json'. All five commands fall back to plain text with a WARN line on stderr in non-TTY contexts.

Changes Made

Added

  • deepl write accepts ja, ko, zh, zh-Hans target languages
  • --tone and --style extended to es, it, fr, pt, pt-BR, pt-PT
  • deepl style-rules full CRUD subcommands (create, show, update, delete)
  • deepl style-rules custom instructions management (instructions, add-instruction, update-instruction, remove-instruction)
  • --format table on style-rules list/instructions (with non-TTY fallback)
  • examples/35-style-rules-crud.sh and examples/36-write-extended-languages.sh — end-to-end workflow demos

Fixed

  • --format table now actually renders on languages, cache stats, and usage (was previously a silent no-op)
  • deepl sync export --output <path> no longer rejects valid output paths under symlinked project roots (the macOS /tmp/private/tmp case). assertPathWithinRoot now resolves both sides through fs.realpath before comparing. Symlink-based escape attempts are now also rejected as a defense-in-depth bonus.

Security

  • Server-returned error messages are sanitized through sanitizeForTerminal before interpolation into user-facing API error: … and Server error (5xx): … strings. Defense-in-depth against a malicious or buggy server scribbling ANSI escape codes / control characters on the user's terminal via the error path. Mirrors the existing TMS-client hardening.

Backward Compatibility

100% backward-compatible. No existing flag, method, or endpoint renamed or removed. All new subcommands are additive. The WriteLanguage type union extends rather than replacing prior values. The CustomInstruction.id field is optional and only present on responses (never sent on create).

Breaking changes: None.

Test Coverage

  • 5490 unit/integration/e2e tests pass (up from 5448 at branch start)
  • Lint clean, tsc --noEmit clean
  • All 5 touched example scripts validated end-to-end against the live DeepL API:
    • 15-glossaries.sh — 32s
    • 30-sync-basic.sh — 1s
    • 32-sync-live-validation.sh — 6s, 30/30 phase assertions pass
    • 35-style-rules-crud.sh — 6s, 9-phase round-trip
    • 36-write-extended-languages.sh — 7s, JA/KO/ZH + ES/IT/FR/PT round-trip

Technical Details

The most consequential change is the configured_rules data shape. The DeepL API models this as a two-level dictionary:

```json
{
"punctuation": {
"quotation_mark": "use_guillemets",
"spacing_and_punctuation": "do_not_use_space"
},
"spelling_and_grammar": {
"accents_and_cedillas": "use_even_on_capital_letters"
}
}
```

Empty rules are `{}`. The PUT endpoint at /v3/style_rules/{id}/configured_rules takes the rules dict as the entire body (no configured_rules outer wrapper); POST and PATCH on the rule itself wrap it under configured_rules because they have multiple top-level fields.

Custom instructions are addressed in URLs by a server-assigned UUID, not by user-facing label. The CLI's positional argument is the label (per the instructions <style-id> + flat sibling pattern matching the glossary precedent), so per-instruction operations do a getStyleRule(detailed=true) lookup to resolve label → id before hitting the URL path. One extra GET per mutation; acceptable for a CLI.

Benefits

  • Code quality: 23 atomic conventional-commits, one logical change per commit. Module separation preserved (types → wire/mapper → client → service → command → register).
  • Maintainability: New table formatters share the established pattern from translate --format table (cli-table3 + isColorEnabled() for NO_COLOR + non-TTY fallback). Future commands gain --format table consistently.
  • Performance: No regressions. The label → id lookup adds one round-trip per per-instruction operation but is acceptable for CLI usage.
  • Security: Two new hardenings (symlink resolution + error-message sanitization) bring the main DeepL HTTP client to parity with the existing TMS-client posture.

Size: Medium ✓

23 source/test commits + 1 release-cut commit. 45 files changed, +4135 / −135. Two design-council reviews on file (2026-04-24 ship review, 2026-04-25 final pre-ship review); both verdict SHIP.

🤖 Generated with Claude Code

sjsyrek and others added 24 commits April 24, 2026 08:32
Extract validLanguages/validStyles/validTones into module-scope
WRITE_LANGUAGES/WRITE_STYLES/WRITE_TONES as const arrays, consumed
by both the commander option descriptions (interpolated) and the
validator branches. Prevents help-text/validator drift as the
accepted language and style sets grow.

No behavior change.
Extend the DeepL Write API surface to match the March 2026
language additions: Japanese (ja), Korean (ko), and Simplified
Chinese (zh, zh-Hans) are now valid --lang values.

- WriteLanguage union (src/types/api.ts) grows four literals,
  preserving alphabetical ordering.
- WRITE_LANGUAGES runtime const (register-write.ts) grows four
  codes; validator and --help interpolation pick them up for free.

Tests added:
- unit: parametric accept-case per new code + existing reject-path
  unchanged
- integration: nock body-shape assertion for each new target_lang
  joining the existing "Different Languages" matrix
- e2e: validation-exit-code check per new code (exit != 6)
DeepL's March 2026 Write API extension added server-side support
for --tone and --style on Spanish (es), Italian (it), French (fr),
and Portuguese (pt, pt-BR, pt-PT) target languages. The CLI was
already passing these combinations through unchanged; this commit:

- Integration tests lock in the combined (lang, style) and
  (lang, tone) happy paths so a future server regression would
  be caught by our nock body-shape assertions.
- WriteClient enhances error translation: when a 4xx arrives and
  --style or --tone was sent, the ValidationError is re-thrown
  with a recovery pointer ("See docs/API.md for supported target
  language / style / tone combinations."). Matches the CLI's
  "name the thing and the next command" error convention.
- Unit tests cover three cases: 4xx + style (hint appended),
  4xx + tone (hint appended), 4xx without style/tone (no hint).
Extend StyleRulesClient with the five new endpoints that shipped
with the DeepL API's March 2026 Style Rules additions. Purely the
API layer; no CLI surface in this commit.

Added methods:
  createStyleRule(opts)           -> POST /v3/style_rules
  getStyleRule(id, detailed?)     -> GET  /v3/style_rules/:id
  updateStyleRule(id, opts)       -> PATCH /v3/style_rules/:id
  deleteStyleRule(id)             -> DELETE /v3/style_rules/:id
  replaceConfiguredRules(id, [])  -> PUT /v3/style_rules/:id/configured_rules

- Existing getStyleRules (list) is preserved, not renamed; internal
  snake<->camel mapping hoisted to two small helpers (mapStyleRule,
  mapStyleRuleDetailed) so all 6 methods share one serialization
  boundary.
- New types CreateStyleRuleOptions and UpdateStyleRuleOptions in
  src/types/api.ts; all request bodies use snake_case on the wire
  per DeepL convention, responses are camelCased DTOs to match the
  rest of the client surface.
- Style IDs are URL-encoded in path construction (defensive against
  any server id format we haven't seen).

Tests (14 new, 23 total in file):
- Per method: happy path with request-shape assertion (method+url+body),
  plus one 4xx propagation case.
- getStyleRule: additionally covers detailed=true path and URL-encoding
  of style IDs containing spaces.
- createStyleRule: additionally asserts snake_case serialization of
  customInstructions sourceLanguage field.
- updateStyleRule: empty-options body produces empty object, not
  undefined.
Wire the five new StyleRulesClient methods into the CLI as four
glossary-mirrored subcommands under `deepl style-rules`. The PUT
/configured_rules endpoint folds into `update --rules`; no separate
top-level verb (matches the council's 5-verb top-level surface).

Subcommands added:
  create --name <name> --language <lang> [--rules <csv|json>] [--format text|json]
  show <id> [--detailed] [--format text|json]
  update <id> [--name <new>] [--rules <csv|json>] [--format text|json]
  delete <id> [-y|--yes] [--dry-run]

- `--rules` accepts either a comma-separated list or a JSON array,
  parsed via parseRulesArg() with explicit ValidationError on malformed
  input. No file-reading mode this commit — a user who needs that can
  shell-interpolate `$(cat rules.json)`.
- `update` requires at least one of --name or --rules, else exit 6.
  When both are passed, PATCH runs first (rename), then PUT (rules
  replacement); final state is the detailed rule echoed to the user.
- `delete` inherits glossary's interaction model exactly: TTY confirm
  by default, `--yes` to skip, `--dry-run` to preview. Confirms via
  utils/confirm.js (same module the glossary path uses).
- Text-format rendering of stored user strings (rule name, custom
  instruction label and prompt) passes through sanitizeForTerminal per
  the security-seat consensus; JSON format path preserves raw bytes as
  JSON-escaped (JSON.stringify handles control bytes).
- DeepLClient, StyleRulesService, StyleRulesCommand all grow matching
  proxy methods so the facade chain stays uniform with getStyleRules.

Tests (59 new across 5 files):
- Unit (StyleRulesCommand): proxy-to-service for all 5 methods;
  formatStyleRule / formatStyleRuleJson for basic and detailed rules,
  plus ANSI-escape sanitization cases for both text and JSON paths.
- Unit (StyleRulesService): proxy-to-client for all 5 methods.
- Integration: CRUD invariants 1-3 per the council plan
  (create->show round-trip, update->show matches, delete->show 404),
  replaceConfiguredRules PUT shape assertion, plus CLI flag surface
  tests for the 4 new subcommands (required-args, dry-run preview,
  missing-options exit code).
- E2E: --help output for each new subcommand.

Mock factories extended for the 5 new DeepLClient and StyleRulesService
methods so downstream command-facade unit tests compile.
Extend StyleRulesClient with the four Custom Instructions endpoints
that ship under each Style Rule in the DeepL API's March 2026
additions. No list endpoint exists server-side — the CLI list verb
will synthesize from getStyleRule().customInstructions.

Added methods:
  createCustomInstruction(styleId, opts)
  getCustomInstruction(styleId, label)
  updateCustomInstruction(styleId, label, opts)
  deleteCustomInstruction(styleId, label)

- CustomInstructionWireShape + mapCustomInstruction hoisted alongside
  existing StyleRule mappers; all snake<->camel conversion in one place.
- New types CreateCustomInstructionOptions (label+prompt+sourceLanguage?)
  and UpdateCustomInstructionOptions (prompt?+sourceLanguage?) in
  src/types/api.ts. Update intentionally cannot change the label —
  label is the URL-path identifier.
- styleId and label path segments are both URL-encoded.

Tests (11 new, 34 total in file):
- Per method: happy path with request-shape assertion (method+url+body)
  plus one 4xx propagation case.
- createCustomInstruction: snake_case serialization of sourceLanguage.
- getCustomInstruction: URL-encoding verified for both path segments.
- updateCustomInstruction: empty options produces empty body.
Wire the four Custom Instructions client methods into the CLI as
four glossary-mirrored subcommands. Surface mirrors glossary's dual
pattern exactly — read-noun + flat sibling mutations:

  instructions <style-id>           # list (synthesized from getStyleRule)
  add-instruction <style-id> <label> <prompt> [--source-language <lang>]
  update-instruction <style-id> <label> <prompt> [--source-language <lang>]
  remove-instruction <style-id> <label> [-y|--yes] [--dry-run]

- listInstructions reads the nested customInstructions[] from a
  detailed getStyleRule response — no separate LIST endpoint exists
  server-side. This is a deliberate read-verb-as-noun, not a shortcut.
- remove-instruction inherits the destructive-op UX per the council
  consensus (user-authored text warrants confirmation): TTY confirm
  by default, --yes to skip, --dry-run to preview.
- update-instruction cannot change the label — label is the
  URL-path identifier; callers rename by remove + add.
- Text-format output of CustomInstruction.label and .prompt passes
  through sanitizeForTerminal; JSON path unchanged (JSON.stringify
  escapes control bytes at the encoding layer).
- DeepLClient, StyleRulesService, StyleRulesCommand all grow matching
  proxy methods so the facade chain stays uniform with Style Rules.

Tests (35 new across 5 files):
- Unit (StyleRulesCommand): listInstructions synthesis from detailed
  getStyleRule (both with-array and without-array response branches);
  proxy-to-service for add/update/remove; formatCustomInstruction for
  basic + sourceLanguage variant; ANSI sanitization in text, JSON
  preservation of raw strings.
- Unit (StyleRulesService): proxy-to-client for all 4 methods.
- Integration: Custom Instructions create->get round-trip, list-from-
  detailed-getStyleRule synthesis, update->delete sequence; CLI flag
  surface tests for the 4 new subcommands (required-args, --dry-run
  preview, --source-language flag acceptance).
- E2E: --help output for each new subcommand.

Mock factories extended for the 4 new client+service methods.
Close out the API-parity bundle with the user-facing documentation
that tells someone reading a release note exactly what changed and
lets them copy-paste a working invocation.

CHANGELOG.md [Unreleased]:
- Six Added bullets: Write CJK langs; Write tone/style on Romance
  variants; Write 4xx docs-hint; Style Rules CRUD; custom instructions
  management; two new examples.
- All bullets describe user-visible behavior, not internal mechanics.
- No bead ids, council jargon, or ADR refs — public-remote clean.

docs/API.md:
- Write section: --lang list updated to 14 codes; "Supports 14 target
  languages" synopsis; new target-language / style-and-tone compat
  table. The 4xx-hint behavior is documented alongside the table so
  users who hit the unsupported-combination error know where to look.
- style-rules section: full rewrite. The stale "created via web UI,
  not through the API" note is gone; list is preserved verbatim and
  eight new subcommands (create / show / update / delete / instructions
  / add-instruction / update-instruction / remove-instruction) get
  synopsis + args + options + examples each. The text-output
  sanitization contract is called out in Notes.

examples/35-style-rules-crud.sh:
- End-to-end lifecycle script: create rule (JSON output to capture
  the id), show detailed, replace configured rules, add/list/update/
  remove custom instruction, rename, delete. Cleanup trap removes the
  style rule on mid-script failure. Free-tier API keys short-circuit
  gracefully — the Pro-only error is expected and the script exits 0.

examples/36-write-extended-languages.sh:
- Ten-step demo: JA/KO/ZH/zh-Hans target-language invocations plus
  tone/style on ES/IT/FR/PT-BR/PT-PT, concluding with an auto-detect
  rephrase of a Korean input to verify the round-trip.

examples/run-all.sh + examples/README.md:
- Both scripts registered in the full and fast EXAMPLES arrays and
  in the README Write / Configuration sections.
Seven integration tests added in the Style Rules CRUD and
custom-instructions commits asserted error output would match
/API key|auth/i, which is only true when the test harness runs
without a DEEPL_API_KEY set. With a real key the subcommands
reach the network, the server rejects the fake "sr-1" style id
with a 400, and the assertion fails.

The existing `list`-shape tests in the same describe block do not
fail the same way because list returns an empty result on a real
free-tier key — the catch block is never entered and the assertion
is never evaluated. Commands that take a positional id do not have
that accidental escape.

Fix: pass `excludeApiKey: true` to runCLI on the seven affected
tests, matching the pattern already used by the "without API key"
describe block above. Also applied to the `create --name Foo
--language en` test, which would otherwise hit the network on a
real key and create an actual style rule — a side-effect worse
than a flaky failure.

Tests still pass against both keyless and keyed test environments;
no source code change.
Coverage threshold dipped to 93.5% (below the 94% floor) after the
Style Rules CRUD and custom-instructions commits added 8 new
subcommand action handlers. The integration tests that exercise
them via runCLI run in a subprocess, so Jest's parent-process
coverage instrumentation doesn't see them.

Add in-process unit tests that drive each handler directly via
commander's parseAsync, with createStyleRulesCommand and Logger
mocked. Covers:
  - list: text + JSON output paths, error handling
  - create: required flags, --rules parsing (CSV + JSON array +
    malformed + non-string-array rejection), JSON output
  - show: basic + --detailed + JSON + error propagation
  - update: PATCH-only, PUT-only, both-at-once, missing-both
    ValidationError, JSON output
  - delete: --yes fast-path, --dry-run preview, confirmed prompt,
    declined-prompt abort
  - instructions: text + JSON, error path
  - add-instruction: required positionals, --source-language,
    JSON output
  - update-instruction: same three
  - remove-instruction: --yes, --dry-run, declined prompt

register-style-rules.ts function coverage: 12.5% -> 100%.
Global function coverage: 93.5% -> 94.57%.
…t table

The DeepL API models configured_rules as a two-level dictionary
(category → setting → value), not a flat list of rule IDs. The
implementation typed it as string[], which caused PUT requests to fail
with a server-side type-conversion error and silently elided the Rules:
line in text output (length on a runtime object is undefined).

Fixes:
- Add ConfiguredRules type = Record<string, Record<string, string>>
- Wire shape, mapper, formatters (text + new table), service signatures
  all carry the dict shape.
- PUT /configured_rules sends the rules dict as the body directly; the
  configured_rules outer wrapper is only used on POST/PATCH where the
  body has multiple top-level fields.
- --rules now requires JSON object input; rejects arrays and validates
  the two-level shape with clear error messages.

Also adds --format table to style-rules list and instructions
(addressed by the prior council's UX review), with a non-TTY fallback
to plain text matching the translate --format table pattern.

Tests: integration assertions now verify the unwrapped PUT body and the
nested rule shape; unit tests cover the new parser branches, the new
table formatters (including ANSI sanitization on user-authored keys
and values), and the TTY-vs-non-TTY handler paths.

Example 35 updated: switches to fr (the punctuation/quotation_mark rule
applies only to French) and replaces the now-invalid CSV --rules form
with a JSON object. Style-id capture switches from grep+sed to jq,
which avoids tripping on colored grep output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…igned id

The DeepL API's per-instruction endpoints
(GET/PUT/DELETE /v3/style_rules/{style_id}/custom_instructions/{instruction_id})
take a server-assigned UUID as the URL path segment, not the
user-facing label. The CLI was sending label in the URL, producing
"Invalid or missing instruction id" 400s on every update/delete/get.

The CLI's user-facing positional argument is the label (per the
2026-04-23 council UX decision), so the client now does a label→id
lookup via a detailed getStyleRule before each per-instruction
operation. This costs one extra GET per mutation, acceptable for a CLI.

Also fixes:
- The PUT body for update-instruction must include label, even though
  instruction_id is in the URL (server requires it).
- CustomInstruction now carries the optional server-assigned id field;
  the wire mapper preserves it through both the dedicated mapper and
  the rule-detail nested mapping path.
- ValidationError raised with a clear "no custom instruction with
  label X" message when the lookup misses.

Tests: client unit tests now queue a lookup response before the
operation response and assert the URL contains the resolved UUID.
Integration test asserts the full lookup-then-act flow with two nock
scopes per operation. Existing test for missing-label was rewritten to
exercise the lookup path rather than rely on a 404 from the server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prior runs that aborted before the end-of-script cleanup left
glossaries with the demo names on the server. Subsequent runs hit
"Entry already exists" when re-adding the test database entry, and
"Multiple glossaries share the name" when resolving names that had
collected duplicates across many aborted runs.

Adds an upfront step 0 that lists glossaries as JSON, finds every
glossary id matching one of the five demo names the script uses
(tech-terms-demo / tech-terms-renamed / tech-final / business-terms-demo
/ multi-demo), and deletes each by UUID. UUID-based deletion sidesteps
the multi-match disambiguation error. Falls back to a noop with a
warning when jq is not installed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…acOS symlink mismatch

The script cd's into /tmp/deepl-sync-demo and then ran
`deepl sync export --output "$PROJECT_DIR/handoff.xlf"`. On macOS /tmp
is a symlink to /private/tmp, so process.cwd() in node returns the
resolved /private/tmp/... path while $PROJECT_DIR retains the
unresolved /tmp/... form. loadSyncConfig captures the resolved
/private/tmp form for projectRoot; --output stays as /tmp/...; and
assertPathWithinRoot uses path.resolve (which does not follow
symlinks) so the two never match and the export aborts with "Target
path escapes project root".

The script already cd's into the project dir, so a relative path works
correctly (path.resolve(projectRoot, 'handoff.xlf') stays inside the
resolved root). The underlying overly-strict path-equality check is
a separate concern affecting any path containing symlinks; it should
be addressed in a dedicated fix to assertPathWithinRoot, not bundled
into an example fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ket config

Three independent bugs in the live-validation script were combining to
exit silently after Phase 1, hiding failures from later phases:

1. ((PASS++)) under set -e exits with status 1 when PASS is 0
   (post-increment evaluates to the OLD value; arithmetic context
   treats 0 as failure). The trap cleanup masked the resulting silent
   exit. Replaced with PASS=$((PASS + 1)) plain assignment, which
   always returns 0.

2. `assert "desc" echo "$X" | python3 -c "..."` parses the pipe at the
   assert call level — assert validates `echo` (always 0) and the
   pipe carries assert's own "✓ desc\n" stdout into python instead of
   the captured JSON. Wrapped each pipeline in a helper function
   (_check_status_json_valid / _check_status_source_en) so assert can
   run them atomically.

3. Phase 7 created the source PO at locales/po/messages.po with no
   source-locale segment in the path, so resolveTargetPath could not
   compute target paths. Moved the source to locales/en/po/messages.po
   so the engine substitutes en → de etc. for targets, and updated the
   leftover "alt path" assertion that had been masking the resulting
   FAIL with `|| true`.

4. Phase 8 used bucket key `xml` — the registered key for Android XML
   is `android_xml`. Switched and added target_path_pattern to handle
   Android's locale-via-directory-suffix layout (values/ → values-de/).

Final result: 30 passed / 0 failed across all nine phases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
\`deepl sync --force\` triggers an interactive y/n confirmation prompt
("Retranslate all keys and bypass cost cap?") via confirm() when
stdin.isTTY is true and --yes is absent (register-sync-root.ts:127-143).
The check is on the --force flag itself, regardless of --dry-run.

Phase 6 had \`deepl sync --dry-run --force\` (no --yes), which is fine
under the Bash tool (stdin is a pipe, the TTY guard short-circuits) but
hangs forever in a user's interactive terminal — confirm() reads from
stdin and there is no input.

Adds --yes to both the run and the assert_exit_code wrapper for that
line. Confirmed end-to-end: 30 passed / 0 failed unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…heck

\`path.resolve\` performs lexical normalization only; it does not follow
symlinks. So a project rooted under a symlinked directory — the macOS
\`/tmp\` → \`/private/tmp\` case is the canonical example — produced
false-positive "Target path escapes project root" rejections whenever
the user typed one form and the sync engine had captured the other.

Concrete failure that surfaced this: example 30 cd's into
\`/tmp/deepl-sync-demo\`, then \`loadSyncConfig\` resolves the project
root via \`process.cwd()\` to \`/private/tmp/deepl-sync-demo\` (Node
follows the symlink). When the script passed \`--output
/tmp/deepl-sync-demo/handoff.xlf\` (the unresolved typed form),
\`/tmp/...\`.startsWith(\`/private/tmp/.../\`) was false → rejected.

Fix: introduce \`realpathOrAncestor\` that calls \`fs.realpathSync\` on
each side of the comparison. \`realpathSync\` only works on existing
paths, but the output side is typically a path that doesn't exist yet
(it's about to be created). \`realpathOrAncestor\` handles that by
walking up to the closest existing ancestor, resolving that, and
re-appending the unresolved tail so the result is the symlink-resolved
form of the would-be path.

Defense-in-depth bonus: symlink-based escapes (a symlink inside the
project pointing outside, e.g. \`<root>/escape -> /etc\`) are now also
rejected, where previously they slipped through the lexical check.

Tests: four new cases under \`assertPathWithinRoot\` exercise (1) symlink
project-root with realpath output, (2) realpath project-root with
symlink output (the example-30 shape), (3) symlink-escape rejection,
(4) non-existent output paths under existing roots. The fixture creates
a real tmpdir + symlink portably under \`os.tmpdir()\`.

Also reverts the workaround in example 30 (commit 75479b9): the
absolute \`$PROJECT_DIR/handoff.xlf\` path is now valid again.

Closes sync-kxri.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-visible: anyone with a project under a symlinked path (the macOS
/tmp → /private/tmp case is canonical, but also home directories that
mount through a symlink, /var → /private/var, custom dev environments)
will see different behavior on commands that pass through
assertPathWithinRoot. Worth surfacing in the release notes alongside
the Added items so the change isn't a surprise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ats, usage

The three commands had advertised \`table\` in their \`--format\` choices
list since 1.x, but the action handlers only branched on \`'json'\`
before falling through to the text path. \`--format table\` therefore
silently produced text output with no warning, no error.

Adds dedicated table formatters to each command class:
- \`LanguagesCommand.formatLanguagesTable\` and \`formatAllLanguagesTable\`
  render Code/Name/Category columns (plus Formality for target lists
  whose rows report it). Empty lists render an empty-state line.
- \`CacheCommand.formatStatsTable\` renders a five-row Metric/Value
  table (Status, Entries, Used, Limit, Usage).
- \`UsageCommand.formatUsageTable\` renders a Resource/Used/Limit/Usage
  table with rows for each present quota dimension (characters,
  account units, API key units or characters, speech-to-text). When
  the response includes a product breakdown, an additional table
  follows with one row per product type.

The handler-side wiring in each register-* file mirrors the established
pattern from \`translate\` and \`style-rules\`: branch on \`'table'\`,
render via the new formatter, and fall back to the existing text
formatter with a \`Logger.warn\` when stdout is not a TTY (cli-table3's
Unicode box-drawing characters are noise in pipes and CI logs).

Tests: nine new formatter unit tests across the three command classes
covering happy path, edge cases (empty lists, zero limits), and the
optional sections (formality column, product breakdown). Twelve new
handler-wiring tests under register-* for the TTY/non-TTY branching;
created \`tests/unit/register-usage.test.ts\` for the previously-untested
register-usage module.

Closes sync-xfow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Investigated sync-uit7 with `--runInBand --detectOpenHandles` and pinned
the cause: nock v14's @mswjs/interceptors backend constructs a synthetic
Node IncomingMessage on every \`replyWithError(...)\` call and never
drains it, leaving an HTTPINCOMINGMESSAGE handle pinned in the test
worker. Six call sites (three in tests/unit/deepl-client.test.ts, three
in integration tests) account for all seven leaked handles.

Verified that none of the available jest knobs help:
- \`forceExit: true\` does NOT suppress the worker-side warning (it fires
  from the worker before the main process forceExit kicks in) and adds
  its own "Force exiting Jest" noise on top.
- \`--runInBand\` does eliminate the warning (no workers means no
  worker-exit warning) but slows the full suite from 38s to 195s — 5×
  cost.

Fixing the leak upstream (nock or @mswjs/interceptors) is out of scope
for this project. Replacing replyWithError with a hand-rolled mock
across six tests would also work but is disproportionate effort for
benign noise.

Recording the finding in jest.config.js so future maintainers don't
chase this same dead end, and adding \`npm run test:debug\` (--runInBand
--detectOpenHandles) so any NEW handle leak that isn't one of these six
known sites surfaces clearly during audit.

Closes sync-uit7 as won't-fix (upstream limitation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…erpolation

The DeepL HTTP client interpolates the server's JSON \`responseData.message\`
field into two user-facing error strings:

  src/api/http-client.ts:393  Server error (5xx): \${message}
  src/api/http-client.ts:399  API error: \${message}

Without sanitization, a buggy or malicious server returning a body
containing ANSI escape codes or control characters could scribble on
the user's terminal — move the cursor, change colors, clear the screen,
overwrite previous output. The TMS client was already hardened against
this (sync-pagq.7); the main client wasn't, and the 2026-04-23 council
flagged the gap as a nice-to-have deferred post-1.2.0.

Sanitizing at the source — once, on the \`message\` local — covers both
downstream interpolation sites plus any other branch that uses it.
\`?? ''\` coalesce because some axios error shapes have no \`.message\`
field and \`sanitizeForTerminal\` expects a string.

Tests: three new cases under the existing error-handling describe in
deepl-client.test.ts — mirrors the TMS-client hardening test pattern at
tests/unit/sync/tms-client.test.ts:253. Covers 4xx, 5xx, and bidi
override codepoints.

Closes sync-knl5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…note

Tech-writer's pre-ship review flagged two minor nits:

1. The "languages/cache stats/usage --format table" entry described a
   bug fix (flag was advertised in --help but the action handler only
   branched on 'json', so --format table silently produced text) yet
   was filed under ### Added. Per Keep-a-Changelog conventions, "flag
   never worked, now does" belongs under ### Fixed. Moved.

2. The custom-instructions Added entry called the positional arguments
   abstractly ("the style-rule id and the instruction label as
   positional arguments") which was incomplete: update-instruction also
   takes a third <prompt> positional. Spelled out the full signature
   for each subcommand.

No code changes — just CHANGELOG hygiene before the 1.2.0 cut.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bump VERSION and package.json to 1.2.0; promote CHANGELOG [Unreleased]
to [1.2.0] - 2026-04-25 with a fresh empty Unreleased stub above;
update docs/API.md header (Version + Last Updated).

Release scope:
- Write API extension: JA/KO/ZH/zh-Hans target languages and
  tone/style support for ES/IT/FR/PT variants
- Style Rules: full CRUD + Custom Instructions CRUD with
  configured-rules dict shape (category → setting → value), label →
  server-id lookup for per-instruction operations
- Table format parity: --format table now actually renders on
  languages/cache stats/usage (was advertised but unwired)
- Sync hardening: assertPathWithinRoot follows symlinks; the macOS
  /tmp → /private/tmp false-positive that broke example 30 is fixed
- Security: server-returned error messages sanitized through
  sanitizeForTerminal before terminal interpolation (defense-in-depth
  parity with TMS client)
- Five fresh example scripts validated end-to-end against the live
  DeepL API: 15-glossaries, 30-sync-basic, 32-sync-live-validation,
  35-style-rules-crud, 36-write-extended-languages

Tests: 5490 passing (up from 5448 at branch start). Lint + typecheck
clean. Two design-council reviews on file (2026-04-24 ship review,
2026-04-25 final pre-ship review) with verdicts SHIP and SHIP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Release v1.2.0: API parity for Write extension and Style Rules CRUD,
plus sync symlink hardening, table-format parity for languages/cache/
usage, and http-client error-message sanitization.

See merge request hack-projects/deepl-cli!4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant