@lionrapid/cli 0.2.2 → 0.4.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,129 @@
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
+ ## 0.4.0
7
+
8
+ ### Changed — behaviour, read this before upgrading
9
+
10
+ - **`push` is now incremental.** It diffs your sources against a new
11
+ `.lionrapid/lock.json` and sends only what changed; a second run of an
12
+ unchanged project pushes nothing. **Commit the lockfile** — like
13
+ `package-lock.json` it is the shared baseline, and a per-machine one would
14
+ make CI and a laptop disagree about what changed.
15
+
16
+ Recovery matters here: a batch the server accepts with `202` is recorded as
17
+ sent, and nothing waits for the job. If that job later dead-letters, a plain
18
+ re-push **skips** those rows. Use `push --backfill-missing` (sends exactly
19
+ what the server is missing) or `push --force` (resends everything).
20
+
21
+ - **`LIONRAPID_API_KEY` is now held to the same trusted-host rule as a saved
22
+ default key.** A `server:` that is not `*.lionrapid.com` or localhost will
23
+ refuse to receive it, because CI is precisely where a real secret meets a
24
+ `lionrapid.yml` that arrives by pull request. Self-hosted or staging: set
25
+ `LIONRAPID_ALLOW_UNTRUSTED_HOST=1` (accepted only as `1`/`true`). This
26
+ knowingly reverses the earlier position that CI sets the variable
27
+ deliberately — in CI the operator and the `server:` value are set by
28
+ different people, so exporting a key is not the same as approving its
29
+ destination.
30
+
31
+ - **`DEFAULT_SERVER` is `https://app.lionrapid.com`.** It was
32
+ `api.lionrapid.com`, which has no DNS record.
33
+
34
+ - **`targetLocales` is optional.** A config carrying only a source stanza is
35
+ valid; `push` uploads sources and warns that `pull` has nothing to do.
36
+ Previously `init` wrote a config its own schema rejected.
37
+
38
+ - **`project:` (free text) is removed.** The API key selects the project and
39
+ nothing in the config does; the field was a label nobody checked. A config
40
+ still carrying one parses and ignores it.
41
+
42
+ ### Added
43
+
44
+ - **`lionrapid init` is one pass** — detect, resolve the server, prompt/verify
45
+ and **save** the key, fetch locales, capture `projectId`, write the config.
46
+ No second `login`, no second `init`, no hand-editing.
47
+ - **`lionrapid whoami`** — which key is about to be used and where it is going.
48
+ Offline, masked to the last four characters, names an env override explicitly.
49
+ - **A project-mismatch guard.** `push` refuses _before sending a single row_
50
+ when the config's `projectId` and the key's project disagree, naming both —
51
+ the fix depends on which one is wrong.
52
+ - **`pull` no longer clobbers local edits.** An edit newer than the server is
53
+ named and kept; `--force` takes the server's version and snapshots yours to
54
+ `.lionrapid/history/` first (gitignored — a local safety copy, not a team
55
+ fact). `push --force` does the same in the other direction.
56
+ - **`push --dry-run`** reports the diff: new, changed, unchanged.
57
+ - **`logout`** removes the only scope when there is one, offers a picker when
58
+ there are several, refuses non-interactively, and `--all` clears everything.
59
+ - **Key-capability guidance** in `doctor`, `whoami` and `push` — a `pk_` cannot
60
+ write or publish targets, said before you hit the 403.
61
+ - **`status --refresh`**, which never downgrades a terminal verdict: a
62
+ `completed` job the server has since purged still reads `completed`.
63
+
64
+ ### Fixed
65
+
66
+ - `doctor` blamed a missing API key when the config was invalid; it now says
67
+ **skipped** and prints the config's actual problems.
68
+ - The no-key error named neither the server it wanted nor the scopes it had.
69
+ - `status` showed jobs finishing **before** they were pushed — offset-less
70
+ server timestamps were parsed as local time against a UTC clock.
71
+ - The credentials path ignored `process.env.HOME`, so the test suite read the
72
+ real `~/.lionrapid/credentials`.
73
+ - `validate` reported "all source keys translated" against **zero** target
74
+ locales.
75
+ - Four NUL bytes in `init.ts` made the file invisible to `grep`, for everyone,
76
+ permanently, because they were committed. A repo-wide guard test now fails on
77
+ any recurrence. Note `file(1)` does **not** detect this.
78
+ - `types check` is proven to run offline; a lint that needs credentials is a
79
+ lint nobody runs in CI.
80
+
81
+ ## 0.3.0 — 2026-08-06
82
+
83
+ ### Added
84
+
85
+ - **`push` reports the jobs it queued, and `lionrapid status` says what became
86
+ of them.** `sync/batch` answers `202 Accepted` — queued, not stored — and the
87
+ CLI discarded that response body at the transport layer (`syncBatch` returned
88
+ `void`). A push whose backend job later failed or dead-lettered was therefore
89
+ indistinguishable from one that worked: `pushed N string(s)` described the
90
+ CLI's own outbox and nothing else. Now:
91
+ - `syncBatch` returns the server's `BatchSyncJobResponse`, and `push` collects
92
+ every `job_ids` entry (not just the legacy `job_id`, so multi-locale batches
93
+ stay observable);
94
+ - `push` prints those ids with a `lionrapid status <job-id>` hint, and says so
95
+ explicitly when a server returns none;
96
+ - new **`lionrapid status [job-id|list]`**: one job's verdict from
97
+ `GET /sync/jobs/{id}` (status, error, retry history, dead-letter reason), or
98
+ the whole local job log newest-first, falling back to `/sync/queue-health`
99
+ and `/sync/dead-letter-queue` when this machine has nothing recorded.
100
+
101
+ - **Push receipts persist in `~/.lionrapid/jobs.json`.** Job id, project,
102
+ file(s), key count, push time and last-seen status, so `status` still answers
103
+ after the terminal is closed — and after the server's ~1h job retention has
104
+ purged the job. Terminal states are recorded and answered from disk with no
105
+ network call; anything unresolved is re-checked. Deliberately a separate file
106
+ from `credentials` (different lifetime and sensitivity — a corrupt history
107
+ must never cost you your login), 0600, written via temp-file + rename, and
108
+ pruned to 500 entries / 30 days.
109
+
110
+ - **Field-level drift guard on the sync response.** `BATCH_SYNC_RESPONSE_FIELDS`
111
+ plus a type-level assertion make a required server field missing from the
112
+ CLI's `SyncBatchResponse` a `tsc --noEmit` failure — `job_id`/`job_ids` were
113
+ silently dropped for exactly as long as nothing checked. (Guard lives in
114
+ `src/`, since tsconfig excludes tests from typechecking.)
115
+
116
+ ### Fixed
117
+
118
+ - **A `GET` 404 is no longer retried as "sync already processing".** That
119
+ retry-on-404 heuristic is the enqueue path's; `GET /sync/jobs/{id}` returns the
120
+ same `SYNC_JOB_NOT_FOUND` code for a job that simply does not exist, which made
121
+ `status` back off three times before answering.
122
+
123
+ - **Every request carries `X-LR-Surface: cli`.** The server's surface registry
124
+ tells a customer which integration surfaces are live, but snippet, WordPress
125
+ and the CLI share the same project-wide key and were indistinguishable — so
126
+ each client now tags its requests with which surface it is. Older servers
127
+ ignore the header; no behavior changes.
128
+
6
129
  ## 0.2.2 — 2026-07-27
7
130
 
8
131
  ### Added
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) |
@@ -99,6 +100,17 @@ Built for CI and AI agents — every networked command is resilient and scriptab
99
100
  zero the run — the rest complete, failures are listed per file (and as
100
101
  `error` fields under `--json`), and the exit code is `5` (partial).
101
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.
102
114
  - **Concurrent `pull`**: namespace fetches fan out with bounded concurrency
103
115
  (`LIONRAPID_CONCURRENCY`, default `6`) and show interactive progress on a TTY.
104
116
  Output stays deterministic — only the network fan-out is parallelized.
@@ -178,7 +190,9 @@ already translated on your website are translated in the app for free.
178
190
 
179
191
  ```bash
180
192
  lionrapid init --project <id> --targets es,fr
181
- 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
182
196
  lionrapid pull --types
183
197
  lionrapid validate --ci
184
198
 
@@ -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>;
@@ -21,6 +105,16 @@ export interface NamespaceBundle {
21
105
  notModified: boolean;
22
106
  /** True on a 423 — the namespace exists but nothing is approved for delivery yet. */
23
107
  locked: boolean;
108
+ /**
109
+ * The project this key resolves to, from `meta.project_uuid`.
110
+ *
111
+ * REAL BUT UNDECLARED: the route sets `response_model=TranslationResponse`
112
+ * yet returns a raw Response so it can attach the ETag header, which bypasses
113
+ * FastAPI model filtering — so the field ships without being in the contract
114
+ * (server task #70). Anyone tidying that route to return the declared model
115
+ * removes it, in a change that reads as cosmetic. Treat as optional forever.
116
+ */
117
+ projectId?: string;
24
118
  }
25
119
  export type FetchImpl = (input: string, init?: RequestInit) => Promise<Response>;
26
120
  export interface ApiClientOptions {
@@ -89,6 +183,18 @@ export declare class NetworkError extends Error {
89
183
  * is dropped with a warning rather than crashing the run.
90
184
  */
91
185
  export declare function normalizeTranslations(raw: unknown): Record<string, ContentUnit>;
186
+ /**
187
+ * Project the sync/batch body onto {@link SyncBatchResponse}, field by field.
188
+ *
189
+ * Written out explicitly rather than cast, so a dropped field is a *runtime*
190
+ * regression a test can see as well as a compile error (see
191
+ * {@link BATCH_SYNC_RESPONSE_FIELDS}). Every field degrades instead of throwing:
192
+ * an older server, a proxy that eats the body, or a 204 must leave push working
193
+ * — you simply get no job ids to report, which the CLI says out loud.
194
+ */
195
+ export declare function normalizeSyncBatchResponse(raw: unknown): SyncBatchResponse;
196
+ /** Project a `SyncJobStatusResponse` (job status, or a DLQ entry) onto {@link SyncJobStatus}. */
197
+ export declare function normalizeSyncJob(raw: unknown): SyncJobStatus;
92
198
  export declare class LionRapidApiClient {
93
199
  private readonly baseUrl;
94
200
  private readonly apiKey;
@@ -117,8 +223,29 @@ export declare class LionRapidApiClient {
117
223
  /**
118
224
  * Register source-row text for `sourceLocale`. Mirrors what the edge does on
119
225
  * first visit when it encounters a missing key. POST /api/integrations/sync/batch.
226
+ *
227
+ * Returns the server's 202 body. It used to return `void` — the response was
228
+ * read and thrown away at the transport layer, which is why a push whose jobs
229
+ * later died looked exactly like one that worked. Callers get the job ids and
230
+ * can poll {@link getSyncJob}.
231
+ */
232
+ syncBatch(sourceLocale: string, namespace: string, items: KeyedTranslation[]): Promise<SyncBatchResponse>;
233
+ /**
234
+ * A queued sync job's state. GET /api/integrations/sync/jobs/{id}.
235
+ *
236
+ * The lookup is project-scoped server-side, and finished non-DLQ jobs are
237
+ * purged after ~1h — an unknown id 404s, which is why the CLI also keeps its
238
+ * own history (a job that completed yesterday can only be answered from disk).
239
+ */
240
+ getSyncJob(jobId: string): Promise<SyncJobStatus>;
241
+ /**
242
+ * Jobs the backend gave up on. GET /api/integrations/sync/dead-letter-queue.
243
+ * Needs the `ops:queue` scope (master keys have it; publishable `pk_` keys
244
+ * do not — those get a 403).
120
245
  */
121
- syncBatch(sourceLocale: string, namespace: string, items: KeyedTranslation[]): Promise<void>;
246
+ getDeadLetterQueue(): Promise<SyncJobStatus[]>;
247
+ /** Queue-wide metrics. GET /api/integrations/sync/queue-health (`ops:queue`). */
248
+ getQueueHealth(): Promise<QueueHealth>;
122
249
  /**
123
250
  * Write target-locale translations as drafts (editor-visible, not public
124
251
  * until mark-ready). PUT /api/integrations/targets/batch.
@@ -152,4 +279,5 @@ export declare class LionRapidApiClient {
152
279
  */
153
280
  private doFetch;
154
281
  }
282
+ export {};
155
283
  //# sourceMappingURL=api-client.d.ts.map
@@ -8,6 +8,28 @@
8
8
  * uses native `fetch`. `fetch` is injectable so the client is unit-testable
9
9
  * without a live server.
10
10
  */
11
+ /**
12
+ * The REQUIRED fields of the server's `BatchSyncJobResponse`, transcribed from
13
+ * `contracts/responses.py` (a field with no default is required there).
14
+ *
15
+ * DRIFT GUARD, field level. `job_id`/`job_ids` existed server-side the whole
16
+ * time and the CLI silently dropped them — a `Promise<void>` cannot be caught
17
+ * by any test, because there is nothing to assert on. This list plus
18
+ * {@link SyncBatchResponseCoversContract} makes that failure a *compile* error:
19
+ * add a required field here without adding it to {@link SyncBatchResponse} and
20
+ * `npm run typecheck` fails, so the next server field can't vanish the same way.
21
+ *
22
+ * (Deliberately not derived from the server's OpenAPI: tm-server is a separate
23
+ * repo, so that check would need a network fetch or a snapshot to drift on its
24
+ * own. Hand-transcribing five names is the cheaper, non-flaky half.)
25
+ */
26
+ export const BATCH_SYNC_RESPONSE_FIELDS = [
27
+ 'job_id',
28
+ 'job_ids',
29
+ 'status',
30
+ 'total_keys',
31
+ 'message',
32
+ ];
11
33
  /** Default number of retries for transient failures. */
12
34
  export const DEFAULT_RETRIES = 3;
13
35
  /** Default per-attempt request timeout. */
@@ -25,6 +47,13 @@ const MAX_RETRY_AFTER_MS = 30_000;
25
47
  * 30/min sync limit died at namespace 31).
26
48
  */
27
49
  const RATE_LIMIT_FALLBACK_MS = 30_000;
50
+ /**
51
+ * Surface self-identification for the server's surface registry: snippet,
52
+ * WordPress and the CLI all share the same project-wide key, so each client
53
+ * tags its requests with which surface it is. Older servers ignore unknown
54
+ * headers — additive and inert.
55
+ */
56
+ const SURFACE_HEADER = { 'X-LR-Surface': 'cli' };
28
57
  /** Transient statuses worth retrying: rate-limit (429) and any 5xx. */
29
58
  function isRetryableStatus(status) {
30
59
  return status === 429 || status >= 500;
@@ -151,6 +180,58 @@ export function normalizeTranslations(raw) {
151
180
  walk(raw, '');
152
181
  return out;
153
182
  }
183
+ function isRecord(value) {
184
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
185
+ }
186
+ function asString(value) {
187
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
188
+ }
189
+ function asStringArray(value) {
190
+ return Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
191
+ }
192
+ /**
193
+ * Project the sync/batch body onto {@link SyncBatchResponse}, field by field.
194
+ *
195
+ * Written out explicitly rather than cast, so a dropped field is a *runtime*
196
+ * regression a test can see as well as a compile error (see
197
+ * {@link BATCH_SYNC_RESPONSE_FIELDS}). Every field degrades instead of throwing:
198
+ * an older server, a proxy that eats the body, or a 204 must leave push working
199
+ * — you simply get no job ids to report, which the CLI says out loud.
200
+ */
201
+ export function normalizeSyncBatchResponse(raw) {
202
+ const body = isRecord(raw) ? raw : {};
203
+ const primary = asString(body['job_id']);
204
+ const ids = asStringArray(body['job_ids']);
205
+ // The server sets job_id = job_ids[0]; tolerate either side being absent so
206
+ // one missing field never costs us the other.
207
+ const jobIds = ids.length > 0 ? ids : primary ? [primary] : [];
208
+ return {
209
+ job_id: primary ?? jobIds[0] ?? '',
210
+ job_ids: jobIds,
211
+ status: asString(body['status']) ?? '',
212
+ total_keys: typeof body['total_keys'] === 'number' ? body['total_keys'] : 0,
213
+ message: asString(body['message']) ?? '',
214
+ };
215
+ }
216
+ /** Project a `SyncJobStatusResponse` (job status, or a DLQ entry) onto {@link SyncJobStatus}. */
217
+ export function normalizeSyncJob(raw) {
218
+ const body = isRecord(raw) ? raw : {};
219
+ return {
220
+ jobId: asString(body['job_id']) ?? '',
221
+ status: asString(body['status']) ?? 'unknown',
222
+ createdAt: asString(body['created_at']),
223
+ startedAt: asString(body['started_at']),
224
+ completedAt: asString(body['completed_at']),
225
+ result: isRecord(body['result']) ? body['result'] : undefined,
226
+ error: asString(body['error']),
227
+ retryCount: typeof body['retry_count'] === 'number' ? body['retry_count'] : 0,
228
+ maxRetries: typeof body['max_retries'] === 'number' ? body['max_retries'] : 0,
229
+ nextRetryAt: asString(body['next_retry_at']),
230
+ retryErrors: asStringArray(body['retry_errors']),
231
+ isDeadLettered: body['is_dead_lettered'] === true,
232
+ deadLetterReason: asString(body['dead_letter_reason']),
233
+ };
234
+ }
154
235
  export class LionRapidApiClient {
155
236
  baseUrl;
156
237
  apiKey;
@@ -196,16 +277,62 @@ export class LionRapidApiClient {
196
277
  /**
197
278
  * Register source-row text for `sourceLocale`. Mirrors what the edge does on
198
279
  * first visit when it encounters a missing key. POST /api/integrations/sync/batch.
280
+ *
281
+ * Returns the server's 202 body. It used to return `void` — the response was
282
+ * read and thrown away at the transport layer, which is why a push whose jobs
283
+ * later died looked exactly like one that worked. Callers get the job ids and
284
+ * can poll {@link getSyncJob}.
199
285
  */
200
286
  async syncBatch(sourceLocale, namespace, items) {
201
287
  const translations = items.map((it) => ({
202
288
  key: compositeKey(sourceLocale, namespace, it.key),
203
289
  value: it.value,
204
290
  }));
205
- await this.request('POST', '/api/integrations/sync/batch', {
291
+ const res = await this.request('POST', '/api/integrations/sync/batch', {
206
292
  translations,
207
293
  format: 'contentunit',
208
294
  });
295
+ return normalizeSyncBatchResponse(res);
296
+ }
297
+ /**
298
+ * A queued sync job's state. GET /api/integrations/sync/jobs/{id}.
299
+ *
300
+ * The lookup is project-scoped server-side, and finished non-DLQ jobs are
301
+ * purged after ~1h — an unknown id 404s, which is why the CLI also keeps its
302
+ * own history (a job that completed yesterday can only be answered from disk).
303
+ */
304
+ async getSyncJob(jobId) {
305
+ const res = await this.request('GET', `/api/integrations/sync/jobs/${encodeURIComponent(jobId)}`);
306
+ return normalizeSyncJob(res);
307
+ }
308
+ /**
309
+ * Jobs the backend gave up on. GET /api/integrations/sync/dead-letter-queue.
310
+ * Needs the `ops:queue` scope (master keys have it; publishable `pk_` keys
311
+ * do not — those get a 403).
312
+ */
313
+ async getDeadLetterQueue() {
314
+ const res = await this.request('GET', '/api/integrations/sync/dead-letter-queue');
315
+ const jobs = isRecord(res) && Array.isArray(res['jobs']) ? res['jobs'] : [];
316
+ return jobs.map(normalizeSyncJob);
317
+ }
318
+ /** Queue-wide metrics. GET /api/integrations/sync/queue-health (`ops:queue`). */
319
+ async getQueueHealth() {
320
+ const res = await this.request('GET', '/api/integrations/sync/queue-health');
321
+ const body = isRecord(res) ? res : {};
322
+ const counts = {};
323
+ if (isRecord(body['status_counts'])) {
324
+ for (const [k, v] of Object.entries(body['status_counts'])) {
325
+ if (typeof v === 'number')
326
+ counts[k] = v;
327
+ }
328
+ }
329
+ return {
330
+ totalJobs: typeof body['total_jobs'] === 'number' ? body['total_jobs'] : 0,
331
+ queueDepth: typeof body['queue_depth'] === 'number' ? body['queue_depth'] : 0,
332
+ statusCounts: counts,
333
+ dlqCount: typeof body['dlq_count'] === 'number' ? body['dlq_count'] : 0,
334
+ workerRunning: body['worker_running'] === true,
335
+ };
209
336
  }
210
337
  /**
211
338
  * Write target-locale translations as drafts (editor-visible, not public
@@ -259,6 +386,9 @@ export class LionRapidApiClient {
259
386
  return {
260
387
  translations: normalizeTranslations(data.translations),
261
388
  version: data.meta?.version != null ? String(data.meta.version) : undefined,
389
+ ...(typeof data.meta?.project_uuid === 'string' && data.meta.project_uuid
390
+ ? { projectId: data.meta.project_uuid }
391
+ : {}),
262
392
  notModified: false,
263
393
  locked: false,
264
394
  };
@@ -305,6 +435,7 @@ export class LionRapidApiClient {
305
435
  // is per-attempt, so every retry gets a fresh budget.
306
436
  res = await this.fetchImpl(`${this.baseUrl}${path}`, {
307
437
  ...init,
438
+ headers: { ...SURFACE_HEADER, ...init.headers },
308
439
  redirect: 'error',
309
440
  signal: AbortSignal.timeout(this.timeoutMs),
310
441
  });
@@ -321,7 +452,13 @@ export class LionRapidApiClient {
321
452
  // Exception: a 404 that's really "a sync is already processing for this
322
453
  // project" is transient. Peek at the body (only for 404s); retry if it's
323
454
  // that case and attempts remain, otherwise hand the body back intact.
324
- if (res.status === 404 && attempt < this.retries) {
455
+ //
456
+ // Writes only: that 404 is the enqueue path's answer. `GET
457
+ // /sync/jobs/{id}` returns the same SYNC_JOB_NOT_FOUND code for a job
458
+ // that genuinely does not exist (unknown id, or one purged after the
459
+ // server's ~1h retention), and `lionrapid status` must answer that
460
+ // immediately instead of backing off three times first.
461
+ if (res.status === 404 && method !== 'GET' && attempt < this.retries) {
325
462
  const body = await safeText(res);
326
463
  if (isSyncInProgress(body)) {
327
464
  await this.sleep(backoffMs(attempt, Math.random));