@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.
@@ -8,13 +8,52 @@
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;
35
+ /** Default per-attempt request timeout. */
36
+ export const DEFAULT_TIMEOUT_MS = 30_000;
13
37
  /** Base backoff in ms; grows exponentially per attempt, capped at {@link MAX_BACKOFF_MS}. */
14
38
  const BASE_BACKOFF_MS = 300;
15
39
  const MAX_BACKOFF_MS = 10_000;
16
40
  /** Cap on an honored `Retry-After` so a hostile/misconfigured header can't hang the CLI. */
17
41
  const MAX_RETRY_AFTER_MS = 30_000;
42
+ /**
43
+ * Wait for a 429 that carries no `Retry-After` header. tm-server's rate-limit
44
+ * responses send none, and its limits are per-minute windows — the jittered
45
+ * sub-second backoff below can never outlive one, so without this floor every
46
+ * 429 exhausted its retries and was effectively fatal (e.g. a push past the
47
+ * 30/min sync limit died at namespace 31).
48
+ */
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' };
18
57
  /** Transient statuses worth retrying: rate-limit (429) and any 5xx. */
19
58
  function isRetryableStatus(status) {
20
59
  return status === 429 || status >= 500;
@@ -98,6 +137,101 @@ export class NetworkError extends Error {
98
137
  function compositeKey(locale, namespace, key) {
99
138
  return `${locale}:${namespace}:${key}`;
100
139
  }
140
+ /**
141
+ * Coerce a namespace payload into flat `dotted.key` → {@link ContentUnit}.
142
+ *
143
+ * The endpoint takes flat keys on write but answers **nested** on read
144
+ * (`{"nav": {"cart": …}}` for a pushed `nav.cart`), because the SDK resolves
145
+ * nested paths. Every CLI adapter is built on flat dotted keys, so the tree
146
+ * has to be flattened back here or whole subtrees silently vanish. The leaf
147
+ * rule mirrors the server's: a string, or an object carrying `text`, is a
148
+ * value; any other object is a container to descend into. Bare strings are
149
+ * wrapped, a missing `styles` defaults to `{}`, and a genuinely unusable leaf
150
+ * is dropped with a warning rather than crashing the run.
151
+ */
152
+ export function normalizeTranslations(raw) {
153
+ const out = {};
154
+ const walk = (node, prefix) => {
155
+ if (node === null || typeof node !== 'object') {
156
+ console.error(`warning: skipped key "${prefix}" — server returned no usable text`);
157
+ return;
158
+ }
159
+ for (const [key, value] of Object.entries(node)) {
160
+ const path = prefix ? `${prefix}.${key}` : key;
161
+ if (typeof value === 'string') {
162
+ out[path] = { text: value, styles: {} };
163
+ continue;
164
+ }
165
+ if (value !== null && typeof value === 'object') {
166
+ const unit = value;
167
+ if (typeof unit.text === 'string') {
168
+ out[path] = { text: unit.text, styles: (unit.styles ?? {}) };
169
+ }
170
+ else {
171
+ walk(value, path); // a container, not a value
172
+ }
173
+ continue;
174
+ }
175
+ console.error(`warning: skipped key "${path}" — server returned no usable text`);
176
+ }
177
+ };
178
+ if (raw === null || typeof raw !== 'object')
179
+ return {};
180
+ walk(raw, '');
181
+ return out;
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
+ }
101
235
  export class LionRapidApiClient {
102
236
  baseUrl;
103
237
  apiKey;
@@ -105,6 +239,7 @@ export class LionRapidApiClient {
105
239
  fetchImpl;
106
240
  retries;
107
241
  sleep;
242
+ timeoutMs;
108
243
  constructor(opts) {
109
244
  this.baseUrl = opts.baseUrl.replace(/\/$/, '');
110
245
  this.apiKey = opts.apiKey;
@@ -113,6 +248,7 @@ export class LionRapidApiClient {
113
248
  this.retries = Math.max(0, opts.retries ?? DEFAULT_RETRIES);
114
249
  this.sleep =
115
250
  opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
251
+ this.timeoutMs = Math.max(1, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
116
252
  }
117
253
  /** True when this client holds an editor token and can publish (mark-ready). */
118
254
  get canPublish() {
@@ -123,19 +259,80 @@ export class LionRapidApiClient {
123
259
  const res = (await this.request('GET', '/api/integrations/editor/workflow'));
124
260
  return { isPmAdmin: !!res?.is_pm_admin };
125
261
  }
262
+ /**
263
+ * The project's dashboard-configured locales (for `init` prefill).
264
+ * GET /api/integrations/project-config — flat `{ defaultLocale, languages }`;
265
+ * `languages` includes the default locale. Malformed bodies normalize to
266
+ * empty values rather than throwing.
267
+ */
268
+ async getProjectConfig() {
269
+ const res = (await this.request('GET', '/api/integrations/project-config'));
270
+ return {
271
+ defaultLocale: typeof res?.defaultLocale === 'string' ? res.defaultLocale : '',
272
+ languages: Array.isArray(res?.languages)
273
+ ? res.languages.filter((l) => typeof l === 'string')
274
+ : [],
275
+ };
276
+ }
126
277
  /**
127
278
  * Register source-row text for `sourceLocale`. Mirrors what the edge does on
128
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}.
129
285
  */
130
286
  async syncBatch(sourceLocale, namespace, items) {
131
287
  const translations = items.map((it) => ({
132
288
  key: compositeKey(sourceLocale, namespace, it.key),
133
289
  value: it.value,
134
290
  }));
135
- await this.request('POST', '/api/integrations/sync/batch', {
291
+ const res = await this.request('POST', '/api/integrations/sync/batch', {
136
292
  translations,
137
293
  format: 'contentunit',
138
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
+ };
139
336
  }
140
337
  /**
141
338
  * Write target-locale translations as drafts (editor-visible, not public
@@ -187,7 +384,7 @@ export class LionRapidApiClient {
187
384
  }
188
385
  const data = (await res.json());
189
386
  return {
190
- translations: data.translations ?? {},
387
+ translations: normalizeTranslations(data.translations),
191
388
  version: data.meta?.version != null ? String(data.meta.version) : undefined,
192
389
  notModified: false,
193
390
  locked: false,
@@ -231,11 +428,18 @@ export class LionRapidApiClient {
231
428
  try {
232
429
  // `redirect: 'error'` pins every request to the configured host: a 3xx
233
430
  // fails loudly instead of relying on the runtime to strip the
234
- // Authorization header on a cross-origin redirect.
235
- res = await this.fetchImpl(`${this.baseUrl}${path}`, { ...init, redirect: 'error' });
431
+ // Authorization header on a cross-origin redirect. The timeout signal
432
+ // is per-attempt, so every retry gets a fresh budget.
433
+ res = await this.fetchImpl(`${this.baseUrl}${path}`, {
434
+ ...init,
435
+ headers: { ...SURFACE_HEADER, ...init.headers },
436
+ redirect: 'error',
437
+ signal: AbortSignal.timeout(this.timeoutMs),
438
+ });
236
439
  }
237
440
  catch (err) {
238
- netErr = new NetworkError(method, path, err);
441
+ const isTimeout = err instanceof Error && err.name === 'TimeoutError';
442
+ netErr = new NetworkError(method, path, isTimeout ? new Error(`timed out after ${this.timeoutMs}ms`, { cause: err }) : err);
239
443
  // A refused redirect is the server's answer, not a transient failure.
240
444
  if (isRedirectFailure(err))
241
445
  throw netErr;
@@ -245,7 +449,13 @@ export class LionRapidApiClient {
245
449
  // Exception: a 404 that's really "a sync is already processing for this
246
450
  // project" is transient. Peek at the body (only for 404s); retry if it's
247
451
  // that case and attempts remain, otherwise hand the body back intact.
248
- 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) {
249
459
  const body = await safeText(res);
250
460
  if (isSyncInProgress(body)) {
251
461
  await this.sleep(backoffMs(attempt, Math.random));
@@ -261,8 +471,11 @@ export class LionRapidApiClient {
261
471
  return res;
262
472
  throw netErr;
263
473
  }
264
- const retryAfter = res?.status === 429 ? parseRetryAfter(res.headers.get('retry-after'), Date.now()) : null;
265
- const delay = retryAfter ?? backoffMs(attempt, Math.random);
474
+ // A 429 is a rate-limit window, not a blip: honor `Retry-After` when the
475
+ // server sent one, else wait window-sized instead of jittering.
476
+ const delay = res?.status === 429
477
+ ? (parseRetryAfter(res.headers.get('retry-after'), Date.now()) ?? RATE_LIMIT_FALLBACK_MS)
478
+ : backoffMs(attempt, Math.random);
266
479
  await this.sleep(delay);
267
480
  }
268
481
  }
package/dist/cli.d.ts CHANGED
@@ -4,8 +4,7 @@
4
4
  * PO) with the LionRapid backend.
5
5
  *
6
6
  * This file wires up the command surface. Each command's behaviour lives in
7
- * `src/commands/*`; commands not yet implemented print a clear "not implemented
8
- * yet" notice and exit non-zero so scripts never silently succeed.
7
+ * `src/commands/*`.
9
8
  */
10
9
  import { Command } from 'commander';
11
10
  /**