@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 +123 -0
- package/README.md +15 -1
- package/dist/api-client.d.ts +129 -1
- package/dist/api-client.js +139 -2
- package/dist/cli.js +469 -34
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +47 -5
- package/dist/commands/init.d.ts +73 -2
- package/dist/commands/init.js +0 -0
- package/dist/commands/login.d.ts +50 -4
- package/dist/commands/login.js +70 -16
- package/dist/commands/pull.d.ts +15 -1
- package/dist/commands/pull.js +92 -4
- package/dist/commands/push.d.ts +73 -0
- package/dist/commands/push.js +175 -3
- package/dist/commands/status.d.ts +91 -0
- package/dist/commands/status.js +232 -0
- package/dist/commands/whoami.d.ts +50 -0
- package/dist/commands/whoami.js +66 -0
- package/dist/config.d.ts +100 -5
- package/dist/config.js +239 -22
- package/dist/history.d.ts +44 -0
- package/dist/history.js +65 -0
- package/dist/job-history.d.ts +106 -0
- package/dist/job-history.js +163 -0
- package/dist/lockfile.d.ts +90 -0
- package/dist/lockfile.js +110 -0
- package/dist/time.d.ts +27 -0
- package/dist/time.js +53 -0
- package/package.json +1 -1
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
|
|
package/dist/api-client.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/api-client.js
CHANGED
|
@@ -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
|
-
|
|
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));
|