@lionrapid/cli 0.2.2 → 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,55 @@
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
+
6
55
  ## 0.2.2 — 2026-07-27
7
56
 
8
57
  ### 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>;
@@ -89,6 +173,18 @@ export declare class NetworkError extends Error {
89
173
  * is dropped with a warning rather than crashing the run.
90
174
  */
91
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;
92
188
  export declare class LionRapidApiClient {
93
189
  private readonly baseUrl;
94
190
  private readonly apiKey;
@@ -117,8 +213,29 @@ export declare class LionRapidApiClient {
117
213
  /**
118
214
  * Register source-row text for `sourceLocale`. Mirrors what the edge does on
119
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).
120
235
  */
121
- 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>;
122
239
  /**
123
240
  * Write target-locale translations as drafts (editor-visible, not public
124
241
  * until mark-ready). PUT /api/integrations/targets/batch.
@@ -152,4 +269,5 @@ export declare class LionRapidApiClient {
152
269
  */
153
270
  private doFetch;
154
271
  }
272
+ export {};
155
273
  //# 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
@@ -305,6 +432,7 @@ export class LionRapidApiClient {
305
432
  // is per-attempt, so every retry gets a fresh budget.
306
433
  res = await this.fetchImpl(`${this.baseUrl}${path}`, {
307
434
  ...init,
435
+ headers: { ...SURFACE_HEADER, ...init.headers },
308
436
  redirect: 'error',
309
437
  signal: AbortSignal.timeout(this.timeoutMs),
310
438
  });
@@ -321,7 +449,13 @@ export class LionRapidApiClient {
321
449
  // Exception: a 404 that's really "a sync is already processing for this
322
450
  // project" is transient. Peek at the body (only for 404s); retry if it's
323
451
  // that case and attempts remain, otherwise hand the body back intact.
324
- if (res.status === 404 && attempt < this.retries) {
452
+ //
453
+ // Writes only: that 404 is the enqueue path's answer. `GET
454
+ // /sync/jobs/{id}` returns the same SYNC_JOB_NOT_FOUND code for a job
455
+ // that genuinely does not exist (unknown id, or one purged after the
456
+ // server's ~1h retention), and `lionrapid status` must answer that
457
+ // immediately instead of backing off three times first.
458
+ if (res.status === 404 && method !== 'GET' && attempt < this.retries) {
325
459
  const body = await safeText(res);
326
460
  if (isSyncInProgress(body)) {
327
461
  await this.sleep(backoffMs(attempt, Math.random));