@lionrapid/cli 0.4.0 → 0.5.1

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,58 @@
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.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
+
6
58
  ## 0.4.0
7
59
 
8
60
  ### Changed — behaviour, read this before upgrading
@@ -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
@@ -193,6 +249,18 @@ export declare function normalizeTranslations(raw: unknown): Record<string, Cont
193
249
  * — you simply get no job ids to report, which the CLI says out loud.
194
250
  */
195
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;
196
264
  /** Project a `SyncJobStatusResponse` (job status, or a DLQ entry) onto {@link SyncJobStatus}. */
197
265
  export declare function normalizeSyncJob(raw: unknown): SyncJobStatus;
198
266
  export declare class LionRapidApiClient {
@@ -250,7 +318,7 @@ export declare class LionRapidApiClient {
250
318
  * Write target-locale translations as drafts (editor-visible, not public
251
319
  * until mark-ready). PUT /api/integrations/targets/batch.
252
320
  */
253
- targetsBatch(targetLocale: string, namespace: string, items: KeyedTranslation[]): Promise<void>;
321
+ targetsBatch(targetLocale: string, namespace: string, items: KeyedTranslation[]): Promise<TargetBatchResponse>;
254
322
  /**
255
323
  * Publish a single target row. POST /api/integrations/targets/{key}/mark-ready.
256
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.
package/dist/cli.js CHANGED
@@ -602,7 +602,7 @@ export function buildProgram() {
602
602
  .option('--publish', 'in --target mode, mark pushed rows ready (public)')
603
603
  .option('--dry-run', 'parse + count what would be pushed, but make no network calls')
604
604
  .option('--backfill-missing', 'send only the rows the server is missing (asks the server)')
605
- .option('--force', 'send everything, overwriting server rows (saves them to .lionrapid/history/ first)')
605
+ .option('--force', 'ignore the lockfile and resend every row (saves overwritten server rows to .lionrapid/history/). Does NOT override server-side protection of human-committed rows')
606
606
  .option('--json', 'emit the result as JSON on stdout (prose to stderr)')
607
607
  .option('-c, --config <path>', 'path to lionrapid.yml')
608
608
  .action(async (options) => {
@@ -658,7 +658,8 @@ export function buildProgram() {
658
658
  continue;
659
659
  }
660
660
  const same = f.unchanged ? ` (${f.unchanged} unchanged)` : '';
661
- out(` ${f.skipped ? '· skipped (missing)' : '·'} ${f.file}: ${f.count}${same}`);
661
+ const held = f.refused ? ` (${f.refused} not written)` : '';
662
+ out(` ${f.skipped ? '· skipped (missing)' : '·'} ${f.file}: ${f.count}${held}${same}`);
662
663
  }
663
664
  const failed = res.files.filter((f) => f.error);
664
665
  if (failed.length > 0) {
@@ -669,6 +670,25 @@ export function buildProgram() {
669
670
  out('⚠ uploaded as drafts but not published — publishing needs an editor session token.\n' +
670
671
  ' Set LIONRAPID_EDITOR_TOKEN, or publish in the dashboard.');
671
672
  }
673
+ // Rows the server declined to overwrite. Same voice as pull's
674
+ // kept-local warning: name the key and the reason, because a bare
675
+ // count tells you something happened without telling you what.
676
+ const refused = [
677
+ ...res.protected.map((r) => ({ ...r, kind: 'protected' })),
678
+ ...res.denied.map((r) => ({ ...r, kind: 'denied' })),
679
+ ];
680
+ if (refused.length > 0) {
681
+ out(`⚠ ${refused.length} row(s) were NOT overwritten — the server is protecting them:`);
682
+ const width = Math.max(...refused.map((r) => r.key.length));
683
+ for (const r of refused) {
684
+ const current = r.current_text ? ` (server has: "${r.current_text}")` : '';
685
+ out(` ${r.key.padEnd(width)} ${r.reason}${current}`);
686
+ }
687
+ // There is deliberately no override flag, so do not imply one.
688
+ // These are the paths that DO change a committed row.
689
+ out(' A human committed these. There is no CLI override — change them in the');
690
+ out(' Visual Editor, or use "Use for all" to apply one wording across the project.');
691
+ }
672
692
  // The whole point: "pushed" above is what we SENT. The server stores
673
693
  // it in the background, so print what to watch and where it is saved.
674
694
  if (res.jobs.length > 0) {
@@ -14,7 +14,7 @@
14
14
  * via `targets/batch`, optionally publishing them with `--publish`.
15
15
  */
16
16
  import { type LionRapidConfig } from '../config.js';
17
- import type { LionRapidApiClient } from '../api-client.js';
17
+ import type { LionRapidApiClient, TargetRowOutcome } from '../api-client.js';
18
18
  /**
19
19
  * A sync job the server queued for one batch of this push.
20
20
  *
@@ -72,6 +72,11 @@ export interface PushFileResult {
72
72
  skipped?: boolean;
73
73
  /** Keys this file already had on the server, per the lock — not re-sent. */
74
74
  unchanged?: number;
75
+ /**
76
+ * Rows sent for this file that the server did not write — protected,
77
+ * denied, or skipped. Target mode only; `count` is what actually landed.
78
+ */
79
+ refused?: number;
75
80
  /** Set when this file failed (parse or upload) — it was not (fully) pushed. */
76
81
  error?: string;
77
82
  }
@@ -84,6 +89,23 @@ export interface PushResult {
84
89
  pushed: number;
85
90
  /** Keys the lock says the server already has, so they were not sent again. */
86
91
  skipped: number;
92
+ /**
93
+ * Rows the server REFUSED to overwrite because a human committed them.
94
+ *
95
+ * A machine caller (no user identity) does not get to overwrite a
96
+ * translator's committed work, and there is deliberately no override. These
97
+ * were NOT written, so they are not in `pushed`.
98
+ */
99
+ protected: TargetRowOutcome[];
100
+ /** Rows the caller was not authorized to edit. Same class: not written. */
101
+ denied: TargetRowOutcome[];
102
+ /**
103
+ * Rows the SERVER skipped — source row missing, or the value would not
104
+ * parse. Distinct from `protected`: a different cause, and both can be
105
+ * non-zero in one push. Named to avoid colliding with `skipped` above,
106
+ * which is this CLI's lockfile diff.
107
+ */
108
+ serverSkipped: number;
87
109
  /**
88
110
  * What this push is doing, split so `--dry-run` can answer the question you
89
111
  * ran it to ask: am I ADDING strings, or overwriting ones that already
@@ -86,6 +86,21 @@ export async function runPush(config, client, opts) {
86
86
  let skipped = 0;
87
87
  let diffAdded = 0;
88
88
  let diffChanged = 0;
89
+ const protectedRows = [];
90
+ const deniedRows = [];
91
+ let serverSkipped = 0;
92
+ /** Rows the server confirmed it wrote (target mode only). */
93
+ let written = 0;
94
+ /** The same, for the file currently being processed. */
95
+ let writtenForFile = 0;
96
+ /**
97
+ * Rows the server said it REFUSED for the current file, summed from its own
98
+ * buckets. Deliberately not (sent − written): a row whose text already
99
+ * matches is written by nobody and refused by nobody — the server leaves it
100
+ * out of every bucket and out of `total` — so the subtraction would report a
101
+ * no-op as a problem. Seen live: 2 sent, `protected: 1`, `total: 1`.
102
+ */
103
+ let refusedForFile = 0;
89
104
  // INCREMENTAL. The lock says what the server already has, so a second run
90
105
  // sends "4 changed" rather than all 653. No lock (first run, or a fresh
91
106
  // checkout) means we know nothing, which is a backfill — send everything.
@@ -138,6 +153,10 @@ export async function runPush(config, client, opts) {
138
153
  const parseLocale = mode === 'source' ? config.sourceLocale : opts.target;
139
154
  const entries = adapter.parse(readFileSync(absPath, 'utf8'), { locale: parseLocale });
140
155
  const all = entries.map((e) => ({ key: e.key, value: e.value }));
156
+ // Reset per file: the header total is the sum of these, so they have to
157
+ // measure the same thing.
158
+ writtenForFile = 0;
159
+ refusedForFile = 0;
141
160
  const hashes = new Map(all.map((it) => [it.key, hashSource(it.value)]));
142
161
  // Three modes. `--force` and `--backfill-missing` both ASK THE SERVER
143
162
  // rather than trusting the lock, which is what makes them a recovery
@@ -207,9 +226,25 @@ export async function runPush(config, client, opts) {
207
226
  }
208
227
  }
209
228
  else {
210
- await client.targetsBatch(opts.target, namespace, batch);
229
+ const outcome = await client.targetsBatch(opts.target, namespace, batch);
230
+ protectedRows.push(...outcome.protected_rows);
231
+ deniedRows.push(...outcome.denied_rows);
232
+ serverSkipped += outcome.skipped;
233
+ // Count what the SERVER says it wrote, not what we put on the
234
+ // wire. A refused row is not a pushed row — the whole point of
235
+ // this response is that "sent" and "stored" can differ.
236
+ written += outcome.created + outcome.updated;
237
+ writtenForFile += outcome.created + outcome.updated;
238
+ refusedForFile +=
239
+ outcome.protected + outcome.denied + outcome.skipped + outcome.rejected;
211
240
  if (canPublish) {
241
+ // Never mark-ready a row the server declined to write: it would
242
+ // publish whatever is already there under the guise of this
243
+ // push, which is worse than the refusal it followed.
244
+ const refused = new Set([...outcome.protected_rows, ...outcome.denied_rows].map((r) => r.key));
212
245
  for (const it of batch) {
246
+ if (refused.has(`${opts.target}:${namespace}:${it.key}`))
247
+ continue;
213
248
  await client.markReady(opts.target, namespace, it.key);
214
249
  }
215
250
  }
@@ -225,10 +260,13 @@ export async function runPush(config, client, opts) {
225
260
  lock.entries[key] = { ...lock.entries[key], sourceHash: hashes.get(it.key) };
226
261
  }
227
262
  }
228
- pushed += items.length;
263
+ pushed += mode === 'target' ? 0 : items.length;
229
264
  files.push({
230
265
  file: relPath,
231
- count: items.length,
266
+ // Target mode reports what the SERVER wrote; source mode reports what
267
+ // was queued, which is all a 202 can honestly claim.
268
+ count: mode === 'target' ? writtenForFile : items.length,
269
+ ...(mode === 'target' && refusedForFile > 0 ? { refused: refusedForFile } : {}),
232
270
  ...(all.length > items.length ? { unchanged: all.length - items.length } : {}),
233
271
  });
234
272
  }
@@ -254,8 +292,13 @@ export async function runPush(config, client, opts) {
254
292
  writeLock(opts.lockPath, lock);
255
293
  return {
256
294
  mode,
257
- pushed,
295
+ // Target mode reports what the server confirmed it WROTE. Source mode
296
+ // reports what was queued, which is all a 202 can honestly claim.
297
+ pushed: mode === 'target' ? written : pushed,
258
298
  skipped,
299
+ protected: protectedRows,
300
+ denied: deniedRows,
301
+ serverSkipped,
259
302
  ...(projectCheck ? { projectCheck } : {}),
260
303
  ...(mode === 'source'
261
304
  ? { diff: { added: diffAdded, changed: diffChanged, unchanged: skipped } }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lionrapid/cli",
3
- "version": "0.4.0",
4
- "description": "LionRapid CLI — sync translation files (iOS, Android, Flutter, JSON, YAML, PO) with the LionRapid backend",
3
+ "version": "0.5.1",
4
+ "description": "LionRapid CLI \u2014 sync translation files (iOS, Android, Flutter, JSON, YAML, PO) with the LionRapid backend",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -67,7 +67,10 @@
67
67
  },
68
68
  "repository": {
69
69
  "type": "git",
70
- "url": "git+https://github.com/Hatamoto/tm-integration.git",
71
- "directory": "packages/cli"
70
+ "url": "git+https://github.com/LionRapid/lionrapid-sdk.git"
71
+ },
72
+ "homepage": "https://docs.lionrapid.com",
73
+ "bugs": {
74
+ "url": "https://github.com/LionRapid/lionrapid-sdk/issues"
72
75
  }
73
76
  }