@lionrapid/cli 0.3.0 → 0.5.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,7 +3,134 @@
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
6
+ ## 0.5.0
7
+
8
+ ### Changed — behaviour
9
+
10
+ - **`pushed` now reports what the server wrote, not what went on the wire.** In
11
+ target mode it is the server's own `created + updated`. The same push can
12
+ therefore report a smaller number than before — that number was previously
13
+ describing the CLI's outbox. Source mode is unchanged: a `202` can only
14
+ honestly claim "queued".
15
+
16
+ ### Added
17
+
18
+ - **The server can decline to overwrite your translators, and the CLI now says
19
+ so.** A push from a master or publishable key no longer overwrites a row a
20
+ human committed — the server skips it and reports which ones back. `push`
21
+ names the key, the reason, and the wording the server kept:
22
+
23
+ ```
24
+ ✓ pushed 0 target string(s) across 1 file(s)
25
+ · locales/es/home.json: 0 (1 not written)
26
+ ⚠ 1 row(s) were NOT overwritten — the server is protecting them:
27
+ es:home:hero.title human_committed (server has: "Envia mas rapido")
28
+ A human committed these. There is no CLI override — change them in the
29
+ Visual Editor, or use "Use for all" to apply one wording across the project.
30
+ ```
31
+
32
+ There is deliberately **no flag to force past this**. The paths to a
33
+ deliberate overwrite are the Visual Editor and "Use for all", both of which
34
+ put a person in the loop. A protected row is also never marked ready.
35
+
36
+ ### Fixed
37
+
38
+ - **`denied` and `denied_rows` have never been visible to anyone.** The client
39
+ discarded the `targets/batch` response body entirely (`Promise<void>`), the
40
+ same way `syncBatch` did before 0.3.0 — so rows the server refused on
41
+ authorization grounds were reported by the server and shown to nobody, for as
42
+ long as the field has existed. The body is now returned and normalised, with a
43
+ `TARGET_BATCH_RESPONSE_FIELDS` drift guard beside the sync one so a third
44
+ dropped field is a typecheck failure.
45
+ - **A row the server simply had nothing to do with is no longer called
46
+ "not written".** An unchanged row lands in no server bucket, and the per-file
47
+ figure was computed as `sent − written`, so a no-op read as a refusal — the
48
+ output claimed a problem the user could not find, because the row it implied
49
+ was refused never appeared in the list. Now derived from the server's own
50
+ buckets.
51
+ - **`push --force` no longer claims to overwrite server values.** It forces past
52
+ the local lockfile diff; it does not and cannot override server-side
53
+ protection. The help text says so.
54
+ - **A target whose source row does not exist is no longer reported as pushed.**
55
+ The server writes nothing and answers `skipped`; the CLI said
56
+ `pushed 1 target string(s)`.
57
+
58
+ ## 0.4.0
59
+
60
+ ### Changed — behaviour, read this before upgrading
61
+
62
+ - **`push` is now incremental.** It diffs your sources against a new
63
+ `.lionrapid/lock.json` and sends only what changed; a second run of an
64
+ unchanged project pushes nothing. **Commit the lockfile** — like
65
+ `package-lock.json` it is the shared baseline, and a per-machine one would
66
+ make CI and a laptop disagree about what changed.
67
+
68
+ Recovery matters here: a batch the server accepts with `202` is recorded as
69
+ sent, and nothing waits for the job. If that job later dead-letters, a plain
70
+ re-push **skips** those rows. Use `push --backfill-missing` (sends exactly
71
+ what the server is missing) or `push --force` (resends everything).
72
+
73
+ - **`LIONRAPID_API_KEY` is now held to the same trusted-host rule as a saved
74
+ default key.** A `server:` that is not `*.lionrapid.com` or localhost will
75
+ refuse to receive it, because CI is precisely where a real secret meets a
76
+ `lionrapid.yml` that arrives by pull request. Self-hosted or staging: set
77
+ `LIONRAPID_ALLOW_UNTRUSTED_HOST=1` (accepted only as `1`/`true`). This
78
+ knowingly reverses the earlier position that CI sets the variable
79
+ deliberately — in CI the operator and the `server:` value are set by
80
+ different people, so exporting a key is not the same as approving its
81
+ destination.
82
+
83
+ - **`DEFAULT_SERVER` is `https://app.lionrapid.com`.** It was
84
+ `api.lionrapid.com`, which has no DNS record.
85
+
86
+ - **`targetLocales` is optional.** A config carrying only a source stanza is
87
+ valid; `push` uploads sources and warns that `pull` has nothing to do.
88
+ Previously `init` wrote a config its own schema rejected.
89
+
90
+ - **`project:` (free text) is removed.** The API key selects the project and
91
+ nothing in the config does; the field was a label nobody checked. A config
92
+ still carrying one parses and ignores it.
93
+
94
+ ### Added
95
+
96
+ - **`lionrapid init` is one pass** — detect, resolve the server, prompt/verify
97
+ and **save** the key, fetch locales, capture `projectId`, write the config.
98
+ No second `login`, no second `init`, no hand-editing.
99
+ - **`lionrapid whoami`** — which key is about to be used and where it is going.
100
+ Offline, masked to the last four characters, names an env override explicitly.
101
+ - **A project-mismatch guard.** `push` refuses _before sending a single row_
102
+ when the config's `projectId` and the key's project disagree, naming both —
103
+ the fix depends on which one is wrong.
104
+ - **`pull` no longer clobbers local edits.** An edit newer than the server is
105
+ named and kept; `--force` takes the server's version and snapshots yours to
106
+ `.lionrapid/history/` first (gitignored — a local safety copy, not a team
107
+ fact). `push --force` does the same in the other direction.
108
+ - **`push --dry-run`** reports the diff: new, changed, unchanged.
109
+ - **`logout`** removes the only scope when there is one, offers a picker when
110
+ there are several, refuses non-interactively, and `--all` clears everything.
111
+ - **Key-capability guidance** in `doctor`, `whoami` and `push` — a `pk_` cannot
112
+ write or publish targets, said before you hit the 403.
113
+ - **`status --refresh`**, which never downgrades a terminal verdict: a
114
+ `completed` job the server has since purged still reads `completed`.
115
+
116
+ ### Fixed
117
+
118
+ - `doctor` blamed a missing API key when the config was invalid; it now says
119
+ **skipped** and prints the config's actual problems.
120
+ - The no-key error named neither the server it wanted nor the scopes it had.
121
+ - `status` showed jobs finishing **before** they were pushed — offset-less
122
+ server timestamps were parsed as local time against a UTC clock.
123
+ - The credentials path ignored `process.env.HOME`, so the test suite read the
124
+ real `~/.lionrapid/credentials`.
125
+ - `validate` reported "all source keys translated" against **zero** target
126
+ locales.
127
+ - Four NUL bytes in `init.ts` made the file invisible to `grep`, for everyone,
128
+ permanently, because they were committed. A repo-wide guard test now fails on
129
+ any recurrence. Note `file(1)` does **not** detect this.
130
+ - `types check` is proven to run offline; a lint that needs credentials is a
131
+ lint nobody runs in CI.
132
+
133
+ ## 0.3.0 — 2026-08-06
7
134
 
8
135
  ### Added
9
136
 
@@ -13,7 +140,6 @@ All notable changes to `@lionrapid/cli`. Format based on
13
140
  `void`). A push whose backend job later failed or dead-lettered was therefore
14
141
  indistinguishable from one that worked: `pushed N string(s)` described the
15
142
  CLI's own outbox and nothing else. Now:
16
-
17
143
  - `syncBatch` returns the server's `BatchSyncJobResponse`, and `push` collects
18
144
  every `job_ids` entry (not just the legacy `job_id`, so multi-locale batches
19
145
  stay observable);
@@ -63,6 +63,62 @@ type NoMissingContractFields<T extends never> = T;
63
63
  * no lint suppression, and it is checked because `tsc --noEmit` covers `src/`.
64
64
  */
65
65
  export type SyncBatchResponseCoversContract = NoMissingContractFields<Exclude<(typeof BATCH_SYNC_RESPONSE_FIELDS)[number], keyof SyncBatchResponse>>;
66
+ /** One row the server declined to write, and why. Shared by denied/protected. */
67
+ export interface TargetRowOutcome {
68
+ /** Composite key of the refused row (`locale:namespace:key`). */
69
+ key: string;
70
+ /** `human_committed`, `locked`, `forbidden` — the server's own word. */
71
+ reason: string;
72
+ /** What the server currently holds, so a caller can show what survived. */
73
+ current_text?: string;
74
+ }
75
+ /**
76
+ * The body of `PUT /api/integrations/targets/batch` — the server's
77
+ * `TargetBatchUpdateResponse`. Wire names, like {@link SyncBatchResponse},
78
+ * because this type IS the contract.
79
+ *
80
+ * THE SERVER REFUSES ROWS AND SAYS WHICH. `denied` (per-row authorization) has
81
+ * been reported for as long as it has existed and the CLI never showed it,
82
+ * because `targetsBatch` returned `void` — the same discard `syncBatch` had
83
+ * before 0.3.0. `protected` (tm-platform#162) joins it: a MACHINE push no
84
+ * longer overwrites a human-committed row, and there is deliberately no
85
+ * override flag.
86
+ *
87
+ * `protected` is NOT `skipped`. `skipped` means the source row was not found
88
+ * or the value would not parse — a different thing, and both can be non-zero
89
+ * in one push.
90
+ */
91
+ export interface TargetBatchResponse {
92
+ status: string;
93
+ /** New target rows written. */
94
+ created: number;
95
+ /** Existing target rows overwritten. */
96
+ updated: number;
97
+ /** Source row missing, or the value would not parse. NOT a refusal. */
98
+ skipped: number;
99
+ /** Rows the caller was not authorized to edit. */
100
+ denied: number;
101
+ denied_rows: TargetRowOutcome[];
102
+ /** Human-committed rows a machine caller must not overwrite. */
103
+ protected: number;
104
+ protected_rows: TargetRowOutcome[];
105
+ /** Rows refused by the directive-parity gate. */
106
+ rejected: number;
107
+ /** Items processed — includes everything above. */
108
+ total: number;
109
+ }
110
+ /**
111
+ * Fields of `TargetBatchUpdateResponse` the CLI must carry.
112
+ *
113
+ * Same drift guard as {@link BATCH_SYNC_RESPONSE_FIELDS}, and for the same
114
+ * reason twice over: this is the SECOND response body the CLI silently
115
+ * dropped. `denied_rows` was reported by the server and invisible to users for
116
+ * its whole life. A field added here without a matching one on
117
+ * {@link TargetBatchResponse} is a `tsc --noEmit` failure.
118
+ */
119
+ export declare const TARGET_BATCH_RESPONSE_FIELDS: readonly ["status", "created", "updated", "skipped", "denied", "denied_rows", "protected", "protected_rows", "rejected", "total"];
120
+ /** Compiles only when every listed field exists on {@link TargetBatchResponse}. */
121
+ export type TargetBatchResponseCoversContract = NoMissingContractFields<Exclude<(typeof TARGET_BATCH_RESPONSE_FIELDS)[number], keyof TargetBatchResponse>>;
66
122
  /**
67
123
  * A sync job's server-side state — `GET /api/integrations/sync/jobs/{id}`
68
124
  * (`SyncJobStatusResponse`). Camel-cased because, unlike
@@ -105,6 +161,16 @@ export interface NamespaceBundle {
105
161
  notModified: boolean;
106
162
  /** True on a 423 — the namespace exists but nothing is approved for delivery yet. */
107
163
  locked: boolean;
164
+ /**
165
+ * The project this key resolves to, from `meta.project_uuid`.
166
+ *
167
+ * REAL BUT UNDECLARED: the route sets `response_model=TranslationResponse`
168
+ * yet returns a raw Response so it can attach the ETag header, which bypasses
169
+ * FastAPI model filtering — so the field ships without being in the contract
170
+ * (server task #70). Anyone tidying that route to return the declared model
171
+ * removes it, in a change that reads as cosmetic. Treat as optional forever.
172
+ */
173
+ projectId?: string;
108
174
  }
109
175
  export type FetchImpl = (input: string, init?: RequestInit) => Promise<Response>;
110
176
  export interface ApiClientOptions {
@@ -183,6 +249,18 @@ export declare function normalizeTranslations(raw: unknown): Record<string, Cont
183
249
  * — you simply get no job ids to report, which the CLI says out loud.
184
250
  */
185
251
  export declare function normalizeSyncBatchResponse(raw: unknown): SyncBatchResponse;
252
+ /**
253
+ * Project the targets/batch body onto {@link TargetBatchResponse}, field by
254
+ * field.
255
+ *
256
+ * Every field degrades to zero/empty rather than throwing. That is not
257
+ * defensive habit: the deployment this was written against does NOT serve
258
+ * `protected`/`protected_rows` yet — its OpenAPI lists status, created,
259
+ * updated, skipped, denied, denied_rows, rejected, rejected_keys, total — so
260
+ * "absent" is the CURRENT reality for most servers, not a theoretical older
261
+ * one. An absent refusal must read as "nothing was refused", never as a crash.
262
+ */
263
+ export declare function normalizeTargetBatchResponse(raw: unknown): TargetBatchResponse;
186
264
  /** Project a `SyncJobStatusResponse` (job status, or a DLQ entry) onto {@link SyncJobStatus}. */
187
265
  export declare function normalizeSyncJob(raw: unknown): SyncJobStatus;
188
266
  export declare class LionRapidApiClient {
@@ -240,7 +318,7 @@ export declare class LionRapidApiClient {
240
318
  * Write target-locale translations as drafts (editor-visible, not public
241
319
  * until mark-ready). PUT /api/integrations/targets/batch.
242
320
  */
243
- targetsBatch(targetLocale: string, namespace: string, items: KeyedTranslation[]): Promise<void>;
321
+ targetsBatch(targetLocale: string, namespace: string, items: KeyedTranslation[]): Promise<TargetBatchResponse>;
244
322
  /**
245
323
  * Publish a single target row. POST /api/integrations/targets/{key}/mark-ready.
246
324
  * Requires an editor session token (the integration key is not authorized).
@@ -30,6 +30,27 @@ export const BATCH_SYNC_RESPONSE_FIELDS = [
30
30
  'total_keys',
31
31
  'message',
32
32
  ];
33
+ /**
34
+ * Fields of `TargetBatchUpdateResponse` the CLI must carry.
35
+ *
36
+ * Same drift guard as {@link BATCH_SYNC_RESPONSE_FIELDS}, and for the same
37
+ * reason twice over: this is the SECOND response body the CLI silently
38
+ * dropped. `denied_rows` was reported by the server and invisible to users for
39
+ * its whole life. A field added here without a matching one on
40
+ * {@link TargetBatchResponse} is a `tsc --noEmit` failure.
41
+ */
42
+ export const TARGET_BATCH_RESPONSE_FIELDS = [
43
+ 'status',
44
+ 'created',
45
+ 'updated',
46
+ 'skipped',
47
+ 'denied',
48
+ 'denied_rows',
49
+ 'protected',
50
+ 'protected_rows',
51
+ 'rejected',
52
+ 'total',
53
+ ];
33
54
  /** Default number of retries for transient failures. */
34
55
  export const DEFAULT_RETRIES = 3;
35
56
  /** Default per-attempt request timeout. */
@@ -213,6 +234,40 @@ export function normalizeSyncBatchResponse(raw) {
213
234
  message: asString(body['message']) ?? '',
214
235
  };
215
236
  }
237
+ /**
238
+ * Project the targets/batch body onto {@link TargetBatchResponse}, field by
239
+ * field.
240
+ *
241
+ * Every field degrades to zero/empty rather than throwing. That is not
242
+ * defensive habit: the deployment this was written against does NOT serve
243
+ * `protected`/`protected_rows` yet — its OpenAPI lists status, created,
244
+ * updated, skipped, denied, denied_rows, rejected, rejected_keys, total — so
245
+ * "absent" is the CURRENT reality for most servers, not a theoretical older
246
+ * one. An absent refusal must read as "nothing was refused", never as a crash.
247
+ */
248
+ export function normalizeTargetBatchResponse(raw) {
249
+ const body = isRecord(raw) ? raw : {};
250
+ const count = (key) => typeof body[key] === 'number' && Number.isFinite(body[key]) ? body[key] : 0;
251
+ const rows = (key) => (Array.isArray(body[key]) ? body[key] : [])
252
+ .filter(isRecord)
253
+ .map((row) => ({
254
+ key: asString(row['key']) ?? '',
255
+ reason: asString(row['reason']) ?? 'unspecified',
256
+ ...(asString(row['current_text']) ? { current_text: row['current_text'] } : {}),
257
+ }));
258
+ return {
259
+ status: asString(body['status']) ?? '',
260
+ created: count('created'),
261
+ updated: count('updated'),
262
+ skipped: count('skipped'),
263
+ denied: count('denied'),
264
+ denied_rows: rows('denied_rows'),
265
+ protected: count('protected'),
266
+ protected_rows: rows('protected_rows'),
267
+ rejected: count('rejected'),
268
+ total: count('total'),
269
+ };
270
+ }
216
271
  /** Project a `SyncJobStatusResponse` (job status, or a DLQ entry) onto {@link SyncJobStatus}. */
217
272
  export function normalizeSyncJob(raw) {
218
273
  const body = isRecord(raw) ? raw : {};
@@ -343,10 +398,11 @@ export class LionRapidApiClient {
343
398
  key: compositeKey(targetLocale, namespace, it.key),
344
399
  value: it.value,
345
400
  }));
346
- await this.request('PUT', '/api/integrations/targets/batch', {
401
+ const res = await this.request('PUT', '/api/integrations/targets/batch', {
347
402
  translations,
348
403
  format: 'contentunit',
349
404
  });
405
+ return normalizeTargetBatchResponse(res);
350
406
  }
351
407
  /**
352
408
  * Publish a single target row. POST /api/integrations/targets/{key}/mark-ready.
@@ -386,6 +442,9 @@ export class LionRapidApiClient {
386
442
  return {
387
443
  translations: normalizeTranslations(data.translations),
388
444
  version: data.meta?.version != null ? String(data.meta.version) : undefined,
445
+ ...(typeof data.meta?.project_uuid === 'string' && data.meta.project_uuid
446
+ ? { projectId: data.meta.project_uuid }
447
+ : {}),
389
448
  notModified: false,
390
449
  locked: false,
391
450
  };