@lionrapid/cli 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,212 @@
3
3
  All notable changes to `@lionrapid/cli`. Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/); this package follows semver.
5
5
 
6
+ ## Unreleased
7
+
8
+ ### Added
9
+
10
+ - **`push` reports the jobs it queued, and `lionrapid status` says what became
11
+ of them.** `sync/batch` answers `202 Accepted` — queued, not stored — and the
12
+ CLI discarded that response body at the transport layer (`syncBatch` returned
13
+ `void`). A push whose backend job later failed or dead-lettered was therefore
14
+ indistinguishable from one that worked: `pushed N string(s)` described the
15
+ CLI's own outbox and nothing else. Now:
16
+
17
+ - `syncBatch` returns the server's `BatchSyncJobResponse`, and `push` collects
18
+ every `job_ids` entry (not just the legacy `job_id`, so multi-locale batches
19
+ stay observable);
20
+ - `push` prints those ids with a `lionrapid status <job-id>` hint, and says so
21
+ explicitly when a server returns none;
22
+ - new **`lionrapid status [job-id|list]`**: one job's verdict from
23
+ `GET /sync/jobs/{id}` (status, error, retry history, dead-letter reason), or
24
+ the whole local job log newest-first, falling back to `/sync/queue-health`
25
+ and `/sync/dead-letter-queue` when this machine has nothing recorded.
26
+
27
+ - **Push receipts persist in `~/.lionrapid/jobs.json`.** Job id, project,
28
+ file(s), key count, push time and last-seen status, so `status` still answers
29
+ after the terminal is closed — and after the server's ~1h job retention has
30
+ purged the job. Terminal states are recorded and answered from disk with no
31
+ network call; anything unresolved is re-checked. Deliberately a separate file
32
+ from `credentials` (different lifetime and sensitivity — a corrupt history
33
+ must never cost you your login), 0600, written via temp-file + rename, and
34
+ pruned to 500 entries / 30 days.
35
+
36
+ - **Field-level drift guard on the sync response.** `BATCH_SYNC_RESPONSE_FIELDS`
37
+ plus a type-level assertion make a required server field missing from the
38
+ CLI's `SyncBatchResponse` a `tsc --noEmit` failure — `job_id`/`job_ids` were
39
+ silently dropped for exactly as long as nothing checked. (Guard lives in
40
+ `src/`, since tsconfig excludes tests from typechecking.)
41
+
42
+ ### Fixed
43
+
44
+ - **A `GET` 404 is no longer retried as "sync already processing".** That
45
+ retry-on-404 heuristic is the enqueue path's; `GET /sync/jobs/{id}` returns the
46
+ same `SYNC_JOB_NOT_FOUND` code for a job that simply does not exist, which made
47
+ `status` back off three times before answering.
48
+
49
+ - **Every request carries `X-LR-Surface: cli`.** The server's surface registry
50
+ tells a customer which integration surfaces are live, but snippet, WordPress
51
+ and the CLI share the same project-wide key and were indistinguishable — so
52
+ each client now tags its requests with which surface it is. Older servers
53
+ ignore the header; no behavior changes.
54
+
55
+ ## 0.2.2 — 2026-07-27
56
+
57
+ ### Added
58
+
59
+ - **`files[].flat` — dotted keys round-trip as dotted.** JSON/YAML targets are
60
+ written nested by default, so a key pushed flat (`hero.title`) came back as
61
+ `{"hero": {"title": …}}` — breaking flat-keyed consumers (CMS imports,
62
+ webhook payloads) that fed the file back in. `flat: true` on a `files` entry
63
+ keeps dotted keys literal on `pull`. Only valid for `json`/`yaml` (every
64
+ other format is flat by nature); glob entries carry the flag onto each
65
+ expanded file.
66
+
67
+ - **`lionrapid login` / `lionrapid logout`.** The credentials file the CLI
68
+ reads finally has an author: `login` verifies the key against the server
69
+ _before_ persisting (a failed ping writes nothing), writes
70
+ `~/.lionrapid/credentials` `0600` in a `0700` dir, and merges entries — bare
71
+ `login` writes `default`, `--server <url>` writes the server-scoped entry
72
+ that 0.2.1's trust boundary requires for non-LionRapid hosts. Omitting
73
+ `--key` gives a hidden interactive prompt; the key is never echoed. `logout`
74
+ removes an entry and deletes the file when empty. At read time the CLI now
75
+ warns (once per file) when the credentials file is world-readable.
76
+ - **Glob file entries with derived namespaces.** `files[].source` may be a
77
+ glob (`locales/en/*.json`); `target` and `namespace` then carry a
78
+ `{basename}` token substituted per matched file with its extension-less
79
+ name. One stanza replaces N hand-written entries (e.g. one file per page
80
+ slug → one namespace per page). Expansion happens at config load, is
81
+ deterministic (sorted), and a glob matching nothing warns instead of
82
+ silently doing nothing. A glob without `{basename}` in _both_ `target` and
83
+ `namespace` is rejected as ambiguous.
84
+ - **Per-attempt request timeouts.** Every request carries a fresh
85
+ `AbortSignal.timeout` budget (default 30 s) so a hung connection can no
86
+ longer block `push`/`pull`/`doctor` forever. Timeouts are retried like other
87
+ transient failures and report "timed out after Xms". Tune via
88
+ `LIONRAPID_TIMEOUT` or the global `--timeout <ms>` flag.
89
+ - **`validate --allow-locked`** downgrades locked (unapproved) namespaces to a
90
+ warning so brand-new namespaces don't fail CI.
91
+ - **Modern `init`: layout detection, prompts, and locale prefill.** `init`
92
+ detects a per-namespace `locales/en/<ns>.json` layout and scaffolds the glob
93
+ stanza for it (flat layouts keep the concrete entry). On a TTY it prompts
94
+ for the project label, server URL, and — new — an optional API key
95
+ (`--key`, stored credentials, or a hidden prompt) used to prefill
96
+ `sourceLocale`/`targetLocales` from the project's dashboard configuration
97
+ via `GET /api/integrations/project-config`. Everything degrades gracefully:
98
+ non-TTY runs never block, and offline/older servers fall back to the static
99
+ defaults. A project that simply has **no target languages yet** is reported
100
+ as exactly that — with the remediation (add them in the dashboard, re-run
101
+ `init --force`) and a warning that the scaffolded `targetLocales` are
102
+ placeholders — rather than being misreported as an unreachable server. The
103
+ overwrite guard fires before any prompt, and next-steps route credentials
104
+ through `lionrapid login` (and skip that advice when a stored credential
105
+ was already used).
106
+ - **`init` re-runs safely and stops asking for what it already knows.** It
107
+ defaults its server to the repo's config or the one you logged into (so
108
+ `lionrapid login` then a bare `lionrapid init` is the whole setup), and
109
+ re-running it is no longer an error: newly-detected files and locales merge
110
+ in and are listed, anything that would _change_ a value you set is reported
111
+ as a conflict (`yours:` / `project:`) and left untouched, and a run with
112
+ nothing new leaves the file byte-identical — hand-edits and comments
113
+ survive. `--force` still rewrites wholesale.
114
+ - **`init` no longer invents locales.** When neither the project nor the repo
115
+ yields target languages, `targetLocales` is written empty with a warning
116
+ saying so, instead of scaffolding a fabricated `[es, fr]` that produced
117
+ phantom pending keys on every later `pull`/`validate`.
118
+ - **`init` detects `.po`, XLIFF and standalone YAML catalogs.** Detection is
119
+ no longer limited to Flutter/iOS/Android/Rails/JSON: conventional catalog
120
+ roots (`po/`, `locale/`, `locales/`, `translations/`, `i18n/`, `src/locale/`,
121
+ `Content/Localization/`, …) are searched for `.po`, `.xlf`/`.xliff` and
122
+ `.yml`/`.yaml`, and the source→target stanza is derived from the files that
123
+ actually exist — covering gettext `locale/{locale}/LC_MESSAGES/…`, flat
124
+ `po/{locale}.po`, locale-suffixed `messages.{locale}.xlf`, and Unreal/Godot's
125
+ `Content/Localization/<Target>/{locale}/…`. Formats accumulate, so a repo
126
+ with both JSON and PO trees gets both stanzas.
127
+ - **`init` detects the locales already in your repo.** Locale resolution is
128
+ now a ladder — explicit `--source`/`--targets`, then the project's server
129
+ config, then locales found on disk by matching the detected `target`
130
+ pattern (`locales/{locale}/…`, `app_{locale}.arb`, `values-{locale}/…`,
131
+ `{locale}.lproj/…`), then static placeholders. Offline or on a project with
132
+ no targets yet, `init` scaffolds what the repo actually has instead of
133
+ guessing. When the server _does_ have targets it stays authoritative, and a
134
+ locale that exists only on disk is reported as a mismatch (`present in your
135
+ files but not configured on the project`) rather than silently merged —
136
+ those files would never be filled by `pull`.
137
+
138
+ ### Changed
139
+
140
+ - **`push` makes partial progress instead of aborting.** One failing
141
+ file/namespace (a rate limit on a many-namespace site, one corrupt source
142
+ file) no longer zeroes the run: the remaining files still upload, failures
143
+ render per file with a summary and appear as `error` fields in the `--json`
144
+ envelope (`ok: false`), and the exit code is 5 (PARTIAL). Because the sync
145
+ endpoints upsert by composite key, re-running `push` retries just the
146
+ failures. When every attempted file fails, the first error is rethrown with
147
+ the usual exit taxonomy.
148
+ - **A `429` without `Retry-After` now waits a rate-limit window (30 s), not
149
+ millisecond jitter.** The server's rate-limit responses carry no
150
+ `Retry-After` header and its limits are per-minute windows — the old jittered
151
+ backoff (~2 s total across all retries) could never outlive one, so every
152
+ 429 exhausted its retries and was effectively fatal. Concretely: a site with
153
+ more than 30 namespaces died mid-push at the sync endpoint's 30/min limit;
154
+ now push rides the window out and completes.
155
+ - **`pull` makes partial progress instead of aborting.** On a multi-namespace
156
+ pull, a failing namespace (or a corrupt local target) no longer zeroes the
157
+ run: successful files still write, failures render per file with a summary
158
+ and appear as `error` fields in the `--json` envelope (`ok: false`), and the
159
+ exit code is 5 (PARTIAL). When every file fails, the first error is rethrown
160
+ with the usual exit taxonomy.
161
+ - **`login` and `doctor` verify via `project-config` (`config:read`).** The
162
+ previous probe (`editor/workflow`) requires `targets:write`, which
163
+ publishable keys don't carry — so pk\_ keys failed verification even though
164
+ the whole standard loop (push/pull/validate/prefill) runs on them. Doctor's
165
+ server check now also echoes the project's configured locales.
166
+ - **`pull` is non-destructive by default.** Existing targets are merged, not
167
+ overwritten: the server wins per key, local-only keys and comments survive
168
+ (comments even on server-updated keys), existing key order is preserved and
169
+ new keys append. Each file reports `{added, updated, keptLocal}` diff counts
170
+ in prose and `--json`; `--dry-run` computes the diff without writing.
171
+ `pull --force` restores the previous wholesale overwrite.
172
+ - **`validate` reports a locked (423) namespace as one namespace-level
173
+ `locked` issue** per locale instead of N spurious `missing` lines. Locked
174
+ still fails `--ci` by default. Envelope note: `issues[].key` is now optional
175
+ and `locked` joins the issue types.
176
+
177
+ ### Fixed
178
+
179
+ - **Nested keys were silently lost on `pull`.** The endpoint accepts flat
180
+ dotted keys on write but answers **nested** on read (a pushed `nav.cart`
181
+ comes back as `{"nav": {"cart": …}}`, because the SDK resolves nested
182
+ paths). Every CLI adapter is built on flat keys, so whole subtrees vanished:
183
+ `pull` wrote fewer keys than it pushed and `validate` reported the missing
184
+ ones forever. `getNamespace` now flattens the tree back to `dotted.key`
185
+ using the server's own leaf rule (a string, or an object carrying `text`, is
186
+ a value; any other object is a container).
187
+ - **`validate` no longer crashes on a malformed server value.** Reading
188
+ `.text` off a value that wasn't a ContentUnit threw
189
+ `Cannot read properties of undefined (reading 'trim')` and exit 1 — an
190
+ uncategorized crash on a normal run. The same normalization pass wraps bare
191
+ strings into `{ text, styles: {} }`, defaults a missing `styles`, and drops
192
+ a genuinely unusable leaf with a warning; `validate` treats an unusable
193
+ value as `empty` rather than assuming shape.
194
+ - **Clear error for keys with non-header-safe characters.** Pasting the
195
+ dashboard's truncated key display (`sk_eng_…last4`) used to surface as a
196
+ misleading "server could not be reached" network error — the U+2026
197
+ ellipsis can't be encoded into the `Authorization` header. The CLI now
198
+ rejects such keys at every resolution point (env, credentials file,
199
+ `login`) with exit 3, naming the offending character and the likely
200
+ truncated-copy cause, before any request is sent.
201
+ - **Security — codegen injection in `types generate`.** With `--server`, keys
202
+ and select-option labels are server-controlled; labels are now whitelisted
203
+ to `[A-Za-z0-9_]+` before reaching type position (non-conforming ones drop,
204
+ all-hostile falls back to `string`) and key escaping `\u`-escapes control
205
+ characters and JS line terminators, so no remote string can inject
206
+ declarations into the generated `.d.ts`.
207
+ - **Security — path containment for config-derived writes.** Pull targets,
208
+ multi-locale catalogs, snapshot outputs, and the generated `.d.ts` path must
209
+ resolve inside the project directory; `../` escapes and absolute paths are
210
+ rejected with exit 2 naming the offending path.
211
+
6
212
  ## 0.2.1 — 2026-07-20
7
213
 
8
214
  Security batch (PLAN items 19–23).
package/README.md CHANGED
@@ -22,6 +22,7 @@ Phase 1 (file-sync dev-loop) is implemented. Command surface:
22
22
  | ----------------- | ------ | ------------------------------------------------------------------- |
23
23
  | `init` | ✅ | Scaffold a `lionrapid.yml` (detects Flutter/iOS/Android/Rails/JS) |
24
24
  | `push` | ✅ | Upload source strings to the backend (server handles MT) |
25
+ | `status [job-id]` | ✅ | What the backend actually did with what `push` queued |
25
26
  | `pull [--types]` | ✅ | Download translations into target files; optionally regen types |
26
27
  | `validate [--ci]` | ✅ | Check local files vs the backend for missing/orphan/empty keys |
27
28
  | `types generate` | ✅ | Generate a typed `.d.ts` from your source files (offline) |
@@ -47,6 +48,35 @@ files:
47
48
  - { format: json, source: app/en.json, target: app/{locale}.json } # → namespace: app
48
49
  ```
49
50
 
51
+ **Glob entries:** `source` may be a glob (`*`/`?`); `target` and `namespace`
52
+ must then carry a `{basename}` token, replaced per matched file with its
53
+ extension-less name. One stanza covers a whole directory of per-page (or
54
+ per-feature) catalogs, each in its own namespace:
55
+
56
+ ```yaml
57
+ files:
58
+ - format: json
59
+ source: locales/en/*.json # locales/en/home.json, pricing.json, …
60
+ target: locales/{locale}/{basename}.json
61
+ namespace: '{basename}' # → namespaces home, pricing, …
62
+ ```
63
+
64
+ Globs expand once at config load (sorted, so behavior is deterministic) —
65
+ `push`/`pull`/`validate` see one concrete entry per matched file, and `doctor`
66
+ reports the expanded count. A glob that matches nothing prints a warning
67
+ instead of silently doing nothing; a glob entry without `{basename}` in _both_
68
+ `target` and `namespace` is rejected as ambiguous.
69
+
70
+ **Flat output:** JSON/YAML targets are written nested by default (a pushed
71
+ `hero.title` comes back as `{"hero": {"title": …}}`). Set `flat: true` on a
72
+ `files` entry to keep dotted keys literal on `pull`, so a flat-keyed file
73
+ round-trips flat for flat-keyed consumers (CMS imports, webhook payloads):
74
+
75
+ ```yaml
76
+ files:
77
+ - { format: json, source: locales/en.json, target: locales/{locale}.json, flat: true }
78
+ ```
79
+
50
80
  ## Reliability & automation
51
81
 
52
82
  Built for CI and AI agents — every networked command is resilient and scriptable.
@@ -61,9 +91,26 @@ Built for CI and AI agents — every networked command is resilient and scriptab
61
91
  - **Exit codes** (stable, gate CI on them): `0` ok · `1` unexpected · `2`
62
92
  validation/config · `3` auth · `4` network/5xx · `5` partial.
63
93
  - **Automatic retries**: transient failures (network error, `5xx`, `429`) are
64
- retried with exponential backoff + jitter; a `429` honors `Retry-After`. Tune
65
- with `LIONRAPID_RETRIES` (default `3`; `0` disables). Writes are safe to retry —
66
- the integration endpoints upsert by composite key.
94
+ retried with exponential backoff + jitter. A `429` honors `Retry-After` when
95
+ the server sends it; when it doesn't, the CLI waits a rate-limit-window 30 s
96
+ instead of jittering, so a many-namespace push rides out a per-minute limit.
97
+ Tune with `LIONRAPID_RETRIES` (default `3`; `0` disables). Writes are safe to
98
+ retry — the integration endpoints upsert by composite key.
99
+ - **Partial progress** (`push`, `pull`): one failing namespace or file doesn't
100
+ zero the run — the rest complete, failures are listed per file (and as
101
+ `error` fields under `--json`), and the exit code is `5` (partial).
102
+ Re-running retries just the failures; pushes upsert, so that's always safe.
103
+ - **Pushed ≠ stored (`push` → `status`)**: `sync/batch` answers `202 Accepted`,
104
+ so `push`'s counts are what the CLI **sent**; the backend stores them in a
105
+ background job that can still fail or dead-letter. `push` therefore prints the
106
+ job ids it queued and saves them to `~/.lionrapid/jobs.json`, and
107
+ `lionrapid status <job-id>` reports the server's verdict — stored, still
108
+ queued, retrying, or dead-lettered (with the reason and every failed attempt).
109
+ Bare `lionrapid status` lists every job this machine pushed, newest first,
110
+ re-checking only the unresolved ones; with nothing recorded it falls back to
111
+ the server's queue health + dead-letter queue. Offline, it still prints the
112
+ local record, labelled `(last known)`. History is capped at 500 entries /
113
+ 30 days and never shares a file with your credentials.
67
114
  - **Concurrent `pull`**: namespace fetches fan out with bounded concurrency
68
115
  (`LIONRAPID_CONCURRENCY`, default `6`) and show interactive progress on a TTY.
69
116
  Output stays deterministic — only the network fan-out is parallelized.
@@ -143,7 +190,9 @@ already translated on your website are translated in the app for free.
143
190
 
144
191
  ```bash
145
192
  lionrapid init --project <id> --targets es,fr
146
- lionrapid push
193
+ lionrapid push # prints the job ids it queued
194
+ lionrapid status # …and what the backend did with them
195
+ lionrapid status <job-id> # the full story on one job
147
196
  lionrapid pull --types
148
197
  lionrapid validate --ci
149
198
 
@@ -3,10 +3,10 @@
3
3
  * node backends). Nested objects flatten to dot-notation keys (`home.cta`) and
4
4
  * unflatten back on serialize, preserving insertion order.
5
5
  */
6
- import type { FormatAdapter, TranslationEntry } from './types.js';
6
+ import type { FormatAdapter, SerializeOptions, TranslationEntry } from './types.js';
7
7
  export declare class JsonAdapter implements FormatAdapter {
8
8
  readonly format: "json";
9
9
  parse(content: string): TranslationEntry[];
10
- serialize(entries: TranslationEntry[]): string;
10
+ serialize(entries: TranslationEntry[], options?: SerializeOptions): string;
11
11
  }
12
12
  //# sourceMappingURL=json.d.ts.map
@@ -3,7 +3,7 @@
3
3
  * node backends). Nested objects flatten to dot-notation keys (`home.cta`) and
4
4
  * unflatten back on serialize, preserving insertion order.
5
5
  */
6
- import { flattenObject, unflattenEntries } from './nested.js';
6
+ import { flatEntriesObject, flattenObject, unflattenEntries } from './nested.js';
7
7
  export class JsonAdapter {
8
8
  format = 'json';
9
9
  parse(content) {
@@ -24,8 +24,9 @@ export class JsonAdapter {
24
24
  flattenObject(data, '', out);
25
25
  return out;
26
26
  }
27
- serialize(entries) {
28
- return `${JSON.stringify(unflattenEntries(entries), null, 2)}\n`;
27
+ serialize(entries, options) {
28
+ const tree = options?.flat ? flatEntriesObject(entries) : unflattenEntries(entries);
29
+ return `${JSON.stringify(tree, null, 2)}\n`;
29
30
  }
30
31
  }
31
32
  //# sourceMappingURL=json.js.map
@@ -5,5 +5,11 @@
5
5
  */
6
6
  import type { TranslationEntry } from './types.js';
7
7
  export declare function flattenObject(obj: Record<string, unknown>, prefix: string, out: TranslationEntry[]): void;
8
+ /**
9
+ * Flat serialize: dotted keys stay literal (`"hero.title"`), nothing re-nests.
10
+ * Built with Object.fromEntries so a hostile key like `__proto__` lands as a
11
+ * plain own property instead of touching the prototype.
12
+ */
13
+ export declare function flatEntriesObject(entries: TranslationEntry[]): Record<string, unknown>;
8
14
  export declare function unflattenEntries(entries: TranslationEntry[], onSkippedKey?: (key: string) => void): Record<string, unknown>;
9
15
  //# sourceMappingURL=nested.d.ts.map
@@ -18,6 +18,14 @@ export function flattenObject(obj, prefix, out) {
18
18
  }
19
19
  }
20
20
  }
21
+ /**
22
+ * Flat serialize: dotted keys stay literal (`"hero.title"`), nothing re-nests.
23
+ * Built with Object.fromEntries so a hostile key like `__proto__` lands as a
24
+ * plain own property instead of touching the prototype.
25
+ */
26
+ export function flatEntriesObject(entries) {
27
+ return Object.fromEntries(entries.map((e) => [e.key, fromContentUnit(e.value)]));
28
+ }
21
29
  /** Path segments that would write onto the prototype chain instead of plain data. */
22
30
  const UNSAFE_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
23
31
  function warnSkippedKey(key) {
@@ -30,6 +30,8 @@ export interface SerializeOptions {
30
30
  locale?: string;
31
31
  /** Source locale, recorded by bilingual formats (e.g. XLIFF `source-language`). */
32
32
  sourceLocale?: string;
33
+ /** Keep dotted keys flat (JSON/YAML) instead of re-nesting them into objects. */
34
+ flat?: boolean;
33
35
  }
34
36
  export interface FormatAdapter {
35
37
  readonly format: FileFormat;
@@ -8,7 +8,7 @@
8
8
  * JSON adapter; this adapter assumes the Rails/locale-rooted shape.)
9
9
  */
10
10
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
11
- import { flattenObject, unflattenEntries } from './nested.js';
11
+ import { flatEntriesObject, flattenObject, unflattenEntries } from './nested.js';
12
12
  function isObject(v) {
13
13
  return v !== null && typeof v === 'object' && !Array.isArray(v);
14
14
  }
@@ -43,8 +43,8 @@ export class YamlAdapter {
43
43
  return out;
44
44
  }
45
45
  serialize(entries, options) {
46
- const nested = unflattenEntries(entries);
47
- const doc = options?.locale ? { [options.locale]: nested } : nested;
46
+ const tree = options?.flat ? flatEntriesObject(entries) : unflattenEntries(entries);
47
+ const doc = options?.locale ? { [options.locale]: tree } : tree;
48
48
  return stringifyYaml(doc);
49
49
  }
50
50
  }
@@ -14,6 +14,90 @@ export interface KeyedTranslation {
14
14
  key: string;
15
15
  value: ContentUnit;
16
16
  }
17
+ /**
18
+ * The 202 body of `POST /api/integrations/sync/batch` — a mirror of the
19
+ * server's `BatchSyncJobResponse` (tm-server
20
+ * `backend/app/domains/integrations/contracts/responses.py`). Field names are
21
+ * the wire names on purpose: this type IS the contract, and
22
+ * {@link BATCH_SYNC_RESPONSE_FIELDS} guards it against server drift.
23
+ *
24
+ * 202 means **queued, not stored**. The rows only reach the database when the
25
+ * job queued here actually runs, so a push that returns 202 for every batch is
26
+ * still compatible with the backend later failing or dead-lettering the work.
27
+ * The job ids are the only way a client can tell those apart — discarding this
28
+ * body (as `syncBatch` used to) is what made a failed sync indistinguishable
29
+ * from a successful one.
30
+ */
31
+ export interface SyncBatchResponse {
32
+ /** First queued job id — the server's legacy alias for `job_ids[0]`. */
33
+ job_id: string;
34
+ /** ALL queued job ids — one per locale group in the batch. */
35
+ job_ids: string[];
36
+ /** Job status at enqueue time (`pending`). */
37
+ status: string;
38
+ /** Number of keys the server accepted into the batch. */
39
+ total_keys: number;
40
+ /** Human-readable message — carries the server's own poll hint. */
41
+ message: string;
42
+ }
43
+ /**
44
+ * The REQUIRED fields of the server's `BatchSyncJobResponse`, transcribed from
45
+ * `contracts/responses.py` (a field with no default is required there).
46
+ *
47
+ * DRIFT GUARD, field level. `job_id`/`job_ids` existed server-side the whole
48
+ * time and the CLI silently dropped them — a `Promise<void>` cannot be caught
49
+ * by any test, because there is nothing to assert on. This list plus
50
+ * {@link SyncBatchResponseCoversContract} makes that failure a *compile* error:
51
+ * add a required field here without adding it to {@link SyncBatchResponse} and
52
+ * `npm run typecheck` fails, so the next server field can't vanish the same way.
53
+ *
54
+ * (Deliberately not derived from the server's OpenAPI: tm-server is a separate
55
+ * repo, so that check would need a network fetch or a snapshot to drift on its
56
+ * own. Hand-transcribing five names is the cheaper, non-flaky half.)
57
+ */
58
+ export declare const BATCH_SYNC_RESPONSE_FIELDS: readonly ["job_id", "job_ids", "status", "total_keys", "message"];
59
+ /** Compiles only when `T` is `never` — i.e. no contract field is unaccounted for. */
60
+ type NoMissingContractFields<T extends never> = T;
61
+ /**
62
+ * The guard itself. Exported as a type (not a value) so it costs no runtime and
63
+ * no lint suppression, and it is checked because `tsc --noEmit` covers `src/`.
64
+ */
65
+ export type SyncBatchResponseCoversContract = NoMissingContractFields<Exclude<(typeof BATCH_SYNC_RESPONSE_FIELDS)[number], keyof SyncBatchResponse>>;
66
+ /**
67
+ * A sync job's server-side state — `GET /api/integrations/sync/jobs/{id}`
68
+ * (`SyncJobStatusResponse`). Camel-cased because, unlike
69
+ * {@link SyncBatchResponse}, this is the CLI's own view rather than a guarded
70
+ * contract mirror.
71
+ *
72
+ * `status` is one of `pending` / `processing` / `retry_pending` / `completed` /
73
+ * `failed`. The server only ever sets `failed` from its dead-letter path, so a
74
+ * `failed` job is one the backend gave up on: those rows were never stored.
75
+ */
76
+ export interface SyncJobStatus {
77
+ jobId: string;
78
+ status: string;
79
+ createdAt?: string;
80
+ startedAt?: string;
81
+ completedAt?: string;
82
+ /** Sync outcome on success (`keys_added`, `keys_updated`, …). */
83
+ result?: Record<string, unknown>;
84
+ error?: string;
85
+ retryCount: number;
86
+ maxRetries: number;
87
+ nextRetryAt?: string;
88
+ /** One entry per failed attempt, oldest first. */
89
+ retryErrors: string[];
90
+ isDeadLettered: boolean;
91
+ deadLetterReason?: string;
92
+ }
93
+ /** Queue-wide metrics — `GET /api/integrations/sync/queue-health`. */
94
+ export interface QueueHealth {
95
+ totalJobs: number;
96
+ queueDepth: number;
97
+ statusCounts: Record<string, number>;
98
+ dlqCount: number;
99
+ workerRunning: boolean;
100
+ }
17
101
  /** Result of a namespace pull. `notModified` is true on a 304 (ETag hit). */
18
102
  export interface NamespaceBundle {
19
103
  translations: Record<string, ContentUnit>;
@@ -41,9 +125,17 @@ export interface ApiClientOptions {
41
125
  retries?: number;
42
126
  /** Injectable delay (defaults to real `setTimeout`). Tests pass a no-op. */
43
127
  sleep?: (ms: number) => Promise<void>;
128
+ /**
129
+ * Per-attempt request timeout in ms (each retry gets a fresh budget).
130
+ * Default {@link DEFAULT_TIMEOUT_MS}. A hung connection must never block
131
+ * push/pull/doctor forever — poison for CI.
132
+ */
133
+ timeoutMs?: number;
44
134
  }
45
135
  /** Default number of retries for transient failures. */
46
136
  export declare const DEFAULT_RETRIES = 3;
137
+ /** Default per-attempt request timeout. */
138
+ export declare const DEFAULT_TIMEOUT_MS = 30000;
47
139
  /**
48
140
  * Parse a `Retry-After` header → ms, or null if absent/unparseable. Accepts the
49
141
  * delta-seconds form (`120`) and the HTTP-date form; clamps to
@@ -68,6 +160,31 @@ export declare class ApiError extends Error {
68
160
  export declare class NetworkError extends Error {
69
161
  constructor(method: string, path: string, cause: unknown);
70
162
  }
163
+ /**
164
+ * Coerce a namespace payload into flat `dotted.key` → {@link ContentUnit}.
165
+ *
166
+ * The endpoint takes flat keys on write but answers **nested** on read
167
+ * (`{"nav": {"cart": …}}` for a pushed `nav.cart`), because the SDK resolves
168
+ * nested paths. Every CLI adapter is built on flat dotted keys, so the tree
169
+ * has to be flattened back here or whole subtrees silently vanish. The leaf
170
+ * rule mirrors the server's: a string, or an object carrying `text`, is a
171
+ * value; any other object is a container to descend into. Bare strings are
172
+ * wrapped, a missing `styles` defaults to `{}`, and a genuinely unusable leaf
173
+ * is dropped with a warning rather than crashing the run.
174
+ */
175
+ export declare function normalizeTranslations(raw: unknown): Record<string, ContentUnit>;
176
+ /**
177
+ * Project the sync/batch body onto {@link SyncBatchResponse}, field by field.
178
+ *
179
+ * Written out explicitly rather than cast, so a dropped field is a *runtime*
180
+ * regression a test can see as well as a compile error (see
181
+ * {@link BATCH_SYNC_RESPONSE_FIELDS}). Every field degrades instead of throwing:
182
+ * an older server, a proxy that eats the body, or a 204 must leave push working
183
+ * — you simply get no job ids to report, which the CLI says out loud.
184
+ */
185
+ export declare function normalizeSyncBatchResponse(raw: unknown): SyncBatchResponse;
186
+ /** Project a `SyncJobStatusResponse` (job status, or a DLQ entry) onto {@link SyncJobStatus}. */
187
+ export declare function normalizeSyncJob(raw: unknown): SyncJobStatus;
71
188
  export declare class LionRapidApiClient {
72
189
  private readonly baseUrl;
73
190
  private readonly apiKey;
@@ -75,6 +192,7 @@ export declare class LionRapidApiClient {
75
192
  private readonly fetchImpl;
76
193
  private readonly retries;
77
194
  private readonly sleep;
195
+ private readonly timeoutMs;
78
196
  constructor(opts: ApiClientOptions);
79
197
  /** True when this client holds an editor token and can publish (mark-ready). */
80
198
  get canPublish(): boolean;
@@ -82,11 +200,42 @@ export declare class LionRapidApiClient {
82
200
  ping(): Promise<{
83
201
  isPmAdmin: boolean;
84
202
  }>;
203
+ /**
204
+ * The project's dashboard-configured locales (for `init` prefill).
205
+ * GET /api/integrations/project-config — flat `{ defaultLocale, languages }`;
206
+ * `languages` includes the default locale. Malformed bodies normalize to
207
+ * empty values rather than throwing.
208
+ */
209
+ getProjectConfig(): Promise<{
210
+ defaultLocale: string;
211
+ languages: string[];
212
+ }>;
85
213
  /**
86
214
  * Register source-row text for `sourceLocale`. Mirrors what the edge does on
87
215
  * first visit when it encounters a missing key. POST /api/integrations/sync/batch.
216
+ *
217
+ * Returns the server's 202 body. It used to return `void` — the response was
218
+ * read and thrown away at the transport layer, which is why a push whose jobs
219
+ * later died looked exactly like one that worked. Callers get the job ids and
220
+ * can poll {@link getSyncJob}.
221
+ */
222
+ syncBatch(sourceLocale: string, namespace: string, items: KeyedTranslation[]): Promise<SyncBatchResponse>;
223
+ /**
224
+ * A queued sync job's state. GET /api/integrations/sync/jobs/{id}.
225
+ *
226
+ * The lookup is project-scoped server-side, and finished non-DLQ jobs are
227
+ * purged after ~1h — an unknown id 404s, which is why the CLI also keeps its
228
+ * own history (a job that completed yesterday can only be answered from disk).
229
+ */
230
+ getSyncJob(jobId: string): Promise<SyncJobStatus>;
231
+ /**
232
+ * Jobs the backend gave up on. GET /api/integrations/sync/dead-letter-queue.
233
+ * Needs the `ops:queue` scope (master keys have it; publishable `pk_` keys
234
+ * do not — those get a 403).
88
235
  */
89
- syncBatch(sourceLocale: string, namespace: string, items: KeyedTranslation[]): Promise<void>;
236
+ getDeadLetterQueue(): Promise<SyncJobStatus[]>;
237
+ /** Queue-wide metrics. GET /api/integrations/sync/queue-health (`ops:queue`). */
238
+ getQueueHealth(): Promise<QueueHealth>;
90
239
  /**
91
240
  * Write target-locale translations as drafts (editor-visible, not public
92
241
  * until mark-ready). PUT /api/integrations/targets/batch.
@@ -120,4 +269,5 @@ export declare class LionRapidApiClient {
120
269
  */
121
270
  private doFetch;
122
271
  }
272
+ export {};
123
273
  //# sourceMappingURL=api-client.d.ts.map