@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 +206 -0
- package/README.md +53 -4
- package/dist/adapters/json.d.ts +2 -2
- package/dist/adapters/json.js +4 -3
- package/dist/adapters/nested.d.ts +6 -0
- package/dist/adapters/nested.js +8 -0
- package/dist/adapters/types.d.ts +2 -0
- package/dist/adapters/yaml.js +3 -3
- package/dist/api-client.d.ts +151 -1
- package/dist/api-client.js +221 -8
- package/dist/cli.d.ts +1 -2
- package/dist/cli.js +448 -23
- package/dist/commands/context.d.ts +9 -0
- package/dist/commands/context.js +28 -2
- package/dist/commands/doctor.d.ts +2 -1
- package/dist/commands/doctor.js +8 -4
- package/dist/commands/init.d.ts +44 -0
- package/dist/commands/init.js +0 -0
- package/dist/commands/login.d.ts +47 -0
- package/dist/commands/login.js +110 -0
- package/dist/commands/pull.d.ts +22 -0
- package/dist/commands/pull.js +140 -21
- package/dist/commands/push.d.ts +36 -0
- package/dist/commands/push.js +61 -16
- package/dist/commands/snapshot.js +3 -2
- package/dist/commands/status.d.ts +76 -0
- package/dist/commands/status.js +221 -0
- package/dist/commands/types.js +2 -1
- package/dist/commands/validate.d.ts +5 -2
- package/dist/commands/validate.js +15 -2
- package/dist/config.d.ts +41 -1
- package/dist/config.js +162 -8
- package/dist/job-history.d.ts +97 -0
- package/dist/job-history.js +175 -0
- package/dist/types-gen/extract.js +7 -1
- package/dist/types-gen/generate.js +9 -1
- package/package.json +2 -2
package/dist/api-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
|
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
|
/**
|