@lionrapid/cli 0.2.0 → 0.2.2

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +38 -3
  3. package/dist/adapters/android-xml.js +29 -6
  4. package/dist/adapters/json.d.ts +2 -2
  5. package/dist/adapters/json.js +4 -3
  6. package/dist/adapters/nested.d.ts +7 -1
  7. package/dist/adapters/nested.js +23 -2
  8. package/dist/adapters/types.d.ts +2 -0
  9. package/dist/adapters/yaml.js +3 -3
  10. package/dist/api-client.d.ts +32 -0
  11. package/dist/api-client.js +106 -6
  12. package/dist/cli.d.ts +1 -2
  13. package/dist/cli.js +240 -21
  14. package/dist/commands/context.d.ts +9 -0
  15. package/dist/commands/context.js +28 -2
  16. package/dist/commands/doctor.d.ts +2 -1
  17. package/dist/commands/doctor.js +8 -4
  18. package/dist/commands/init.d.ts +44 -0
  19. package/dist/commands/init.js +0 -0
  20. package/dist/commands/login.d.ts +47 -0
  21. package/dist/commands/login.js +110 -0
  22. package/dist/commands/pull.d.ts +22 -0
  23. package/dist/commands/pull.js +140 -21
  24. package/dist/commands/push.d.ts +2 -0
  25. package/dist/commands/push.js +39 -16
  26. package/dist/commands/snapshot.js +3 -2
  27. package/dist/commands/types.js +2 -1
  28. package/dist/commands/validate.d.ts +5 -2
  29. package/dist/commands/validate.js +15 -2
  30. package/dist/config.d.ts +46 -1
  31. package/dist/config.js +209 -14
  32. package/dist/types-gen/extract.js +7 -1
  33. package/dist/types-gen/generate.js +9 -1
  34. package/package.json +2 -1
  35. package/dist/adapters/android-xml.d.ts.map +0 -1
  36. package/dist/adapters/android-xml.js.map +0 -1
  37. package/dist/adapters/arb.d.ts.map +0 -1
  38. package/dist/adapters/arb.js.map +0 -1
  39. package/dist/adapters/content.d.ts.map +0 -1
  40. package/dist/adapters/content.js.map +0 -1
  41. package/dist/adapters/index.d.ts.map +0 -1
  42. package/dist/adapters/index.js.map +0 -1
  43. package/dist/adapters/ios-strings.d.ts.map +0 -1
  44. package/dist/adapters/ios-strings.js.map +0 -1
  45. package/dist/adapters/json.d.ts.map +0 -1
  46. package/dist/adapters/json.js.map +0 -1
  47. package/dist/adapters/nested.d.ts.map +0 -1
  48. package/dist/adapters/nested.js.map +0 -1
  49. package/dist/adapters/po.d.ts.map +0 -1
  50. package/dist/adapters/po.js.map +0 -1
  51. package/dist/adapters/types.d.ts.map +0 -1
  52. package/dist/adapters/types.js.map +0 -1
  53. package/dist/adapters/xcstrings.d.ts.map +0 -1
  54. package/dist/adapters/xcstrings.js.map +0 -1
  55. package/dist/adapters/xliff.d.ts.map +0 -1
  56. package/dist/adapters/xliff.js.map +0 -1
  57. package/dist/adapters/yaml.d.ts.map +0 -1
  58. package/dist/adapters/yaml.js.map +0 -1
  59. package/dist/api-client.d.ts.map +0 -1
  60. package/dist/api-client.js.map +0 -1
  61. package/dist/cli.d.ts.map +0 -1
  62. package/dist/cli.js.map +0 -1
  63. package/dist/commands/context.d.ts.map +0 -1
  64. package/dist/commands/context.js.map +0 -1
  65. package/dist/commands/doctor.d.ts.map +0 -1
  66. package/dist/commands/doctor.js.map +0 -1
  67. package/dist/commands/init.d.ts.map +0 -1
  68. package/dist/commands/init.js.map +0 -1
  69. package/dist/commands/pull.d.ts.map +0 -1
  70. package/dist/commands/pull.js.map +0 -1
  71. package/dist/commands/push.d.ts.map +0 -1
  72. package/dist/commands/push.js.map +0 -1
  73. package/dist/commands/scan.d.ts.map +0 -1
  74. package/dist/commands/scan.js.map +0 -1
  75. package/dist/commands/snapshot.d.ts.map +0 -1
  76. package/dist/commands/snapshot.js.map +0 -1
  77. package/dist/commands/types.d.ts.map +0 -1
  78. package/dist/commands/types.js.map +0 -1
  79. package/dist/commands/validate.d.ts.map +0 -1
  80. package/dist/commands/validate.js.map +0 -1
  81. package/dist/config.d.ts.map +0 -1
  82. package/dist/config.js.map +0 -1
  83. package/dist/exit-codes.d.ts.map +0 -1
  84. package/dist/exit-codes.js.map +0 -1
  85. package/dist/index.d.ts.map +0 -1
  86. package/dist/index.js.map +0 -1
  87. package/dist/output.d.ts.map +0 -1
  88. package/dist/output.js.map +0 -1
  89. package/dist/scan/canonical.d.ts.map +0 -1
  90. package/dist/scan/canonical.js.map +0 -1
  91. package/dist/scan/discover.d.ts.map +0 -1
  92. package/dist/scan/discover.js.map +0 -1
  93. package/dist/scan/scanner.d.ts.map +0 -1
  94. package/dist/scan/scanner.js.map +0 -1
  95. package/dist/types-gen/extract.d.ts.map +0 -1
  96. package/dist/types-gen/extract.js.map +0 -1
  97. package/dist/types-gen/generate.d.ts.map +0 -1
  98. package/dist/types-gen/generate.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,197 @@
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.2.2 — 2026-07-27
7
+
8
+ ### Added
9
+
10
+ - **`files[].flat` — dotted keys round-trip as dotted.** JSON/YAML targets are
11
+ written nested by default, so a key pushed flat (`hero.title`) came back as
12
+ `{"hero": {"title": …}}` — breaking flat-keyed consumers (CMS imports,
13
+ webhook payloads) that fed the file back in. `flat: true` on a `files` entry
14
+ keeps dotted keys literal on `pull`. Only valid for `json`/`yaml` (every
15
+ other format is flat by nature); glob entries carry the flag onto each
16
+ expanded file.
17
+
18
+ - **`lionrapid login` / `lionrapid logout`.** The credentials file the CLI
19
+ reads finally has an author: `login` verifies the key against the server
20
+ _before_ persisting (a failed ping writes nothing), writes
21
+ `~/.lionrapid/credentials` `0600` in a `0700` dir, and merges entries — bare
22
+ `login` writes `default`, `--server <url>` writes the server-scoped entry
23
+ that 0.2.1's trust boundary requires for non-LionRapid hosts. Omitting
24
+ `--key` gives a hidden interactive prompt; the key is never echoed. `logout`
25
+ removes an entry and deletes the file when empty. At read time the CLI now
26
+ warns (once per file) when the credentials file is world-readable.
27
+ - **Glob file entries with derived namespaces.** `files[].source` may be a
28
+ glob (`locales/en/*.json`); `target` and `namespace` then carry a
29
+ `{basename}` token substituted per matched file with its extension-less
30
+ name. One stanza replaces N hand-written entries (e.g. one file per page
31
+ slug → one namespace per page). Expansion happens at config load, is
32
+ deterministic (sorted), and a glob matching nothing warns instead of
33
+ silently doing nothing. A glob without `{basename}` in _both_ `target` and
34
+ `namespace` is rejected as ambiguous.
35
+ - **Per-attempt request timeouts.** Every request carries a fresh
36
+ `AbortSignal.timeout` budget (default 30 s) so a hung connection can no
37
+ longer block `push`/`pull`/`doctor` forever. Timeouts are retried like other
38
+ transient failures and report "timed out after Xms". Tune via
39
+ `LIONRAPID_TIMEOUT` or the global `--timeout <ms>` flag.
40
+ - **`validate --allow-locked`** downgrades locked (unapproved) namespaces to a
41
+ warning so brand-new namespaces don't fail CI.
42
+ - **Modern `init`: layout detection, prompts, and locale prefill.** `init`
43
+ detects a per-namespace `locales/en/<ns>.json` layout and scaffolds the glob
44
+ stanza for it (flat layouts keep the concrete entry). On a TTY it prompts
45
+ for the project label, server URL, and — new — an optional API key
46
+ (`--key`, stored credentials, or a hidden prompt) used to prefill
47
+ `sourceLocale`/`targetLocales` from the project's dashboard configuration
48
+ via `GET /api/integrations/project-config`. Everything degrades gracefully:
49
+ non-TTY runs never block, and offline/older servers fall back to the static
50
+ defaults. A project that simply has **no target languages yet** is reported
51
+ as exactly that — with the remediation (add them in the dashboard, re-run
52
+ `init --force`) and a warning that the scaffolded `targetLocales` are
53
+ placeholders — rather than being misreported as an unreachable server. The
54
+ overwrite guard fires before any prompt, and next-steps route credentials
55
+ through `lionrapid login` (and skip that advice when a stored credential
56
+ was already used).
57
+ - **`init` re-runs safely and stops asking for what it already knows.** It
58
+ defaults its server to the repo's config or the one you logged into (so
59
+ `lionrapid login` then a bare `lionrapid init` is the whole setup), and
60
+ re-running it is no longer an error: newly-detected files and locales merge
61
+ in and are listed, anything that would _change_ a value you set is reported
62
+ as a conflict (`yours:` / `project:`) and left untouched, and a run with
63
+ nothing new leaves the file byte-identical — hand-edits and comments
64
+ survive. `--force` still rewrites wholesale.
65
+ - **`init` no longer invents locales.** When neither the project nor the repo
66
+ yields target languages, `targetLocales` is written empty with a warning
67
+ saying so, instead of scaffolding a fabricated `[es, fr]` that produced
68
+ phantom pending keys on every later `pull`/`validate`.
69
+ - **`init` detects `.po`, XLIFF and standalone YAML catalogs.** Detection is
70
+ no longer limited to Flutter/iOS/Android/Rails/JSON: conventional catalog
71
+ roots (`po/`, `locale/`, `locales/`, `translations/`, `i18n/`, `src/locale/`,
72
+ `Content/Localization/`, …) are searched for `.po`, `.xlf`/`.xliff` and
73
+ `.yml`/`.yaml`, and the source→target stanza is derived from the files that
74
+ actually exist — covering gettext `locale/{locale}/LC_MESSAGES/…`, flat
75
+ `po/{locale}.po`, locale-suffixed `messages.{locale}.xlf`, and Unreal/Godot's
76
+ `Content/Localization/<Target>/{locale}/…`. Formats accumulate, so a repo
77
+ with both JSON and PO trees gets both stanzas.
78
+ - **`init` detects the locales already in your repo.** Locale resolution is
79
+ now a ladder — explicit `--source`/`--targets`, then the project's server
80
+ config, then locales found on disk by matching the detected `target`
81
+ pattern (`locales/{locale}/…`, `app_{locale}.arb`, `values-{locale}/…`,
82
+ `{locale}.lproj/…`), then static placeholders. Offline or on a project with
83
+ no targets yet, `init` scaffolds what the repo actually has instead of
84
+ guessing. When the server _does_ have targets it stays authoritative, and a
85
+ locale that exists only on disk is reported as a mismatch (`present in your
86
+ files but not configured on the project`) rather than silently merged —
87
+ those files would never be filled by `pull`.
88
+
89
+ ### Changed
90
+
91
+ - **`push` makes partial progress instead of aborting.** One failing
92
+ file/namespace (a rate limit on a many-namespace site, one corrupt source
93
+ file) no longer zeroes the run: the remaining files still upload, failures
94
+ render per file with a summary and appear as `error` fields in the `--json`
95
+ envelope (`ok: false`), and the exit code is 5 (PARTIAL). Because the sync
96
+ endpoints upsert by composite key, re-running `push` retries just the
97
+ failures. When every attempted file fails, the first error is rethrown with
98
+ the usual exit taxonomy.
99
+ - **A `429` without `Retry-After` now waits a rate-limit window (30 s), not
100
+ millisecond jitter.** The server's rate-limit responses carry no
101
+ `Retry-After` header and its limits are per-minute windows — the old jittered
102
+ backoff (~2 s total across all retries) could never outlive one, so every
103
+ 429 exhausted its retries and was effectively fatal. Concretely: a site with
104
+ more than 30 namespaces died mid-push at the sync endpoint's 30/min limit;
105
+ now push rides the window out and completes.
106
+ - **`pull` makes partial progress instead of aborting.** On a multi-namespace
107
+ pull, a failing namespace (or a corrupt local target) no longer zeroes the
108
+ run: successful files still write, failures render per file with a summary
109
+ and appear as `error` fields in the `--json` envelope (`ok: false`), and the
110
+ exit code is 5 (PARTIAL). When every file fails, the first error is rethrown
111
+ with the usual exit taxonomy.
112
+ - **`login` and `doctor` verify via `project-config` (`config:read`).** The
113
+ previous probe (`editor/workflow`) requires `targets:write`, which
114
+ publishable keys don't carry — so pk\_ keys failed verification even though
115
+ the whole standard loop (push/pull/validate/prefill) runs on them. Doctor's
116
+ server check now also echoes the project's configured locales.
117
+ - **`pull` is non-destructive by default.** Existing targets are merged, not
118
+ overwritten: the server wins per key, local-only keys and comments survive
119
+ (comments even on server-updated keys), existing key order is preserved and
120
+ new keys append. Each file reports `{added, updated, keptLocal}` diff counts
121
+ in prose and `--json`; `--dry-run` computes the diff without writing.
122
+ `pull --force` restores the previous wholesale overwrite.
123
+ - **`validate` reports a locked (423) namespace as one namespace-level
124
+ `locked` issue** per locale instead of N spurious `missing` lines. Locked
125
+ still fails `--ci` by default. Envelope note: `issues[].key` is now optional
126
+ and `locked` joins the issue types.
127
+
128
+ ### Fixed
129
+
130
+ - **Nested keys were silently lost on `pull`.** The endpoint accepts flat
131
+ dotted keys on write but answers **nested** on read (a pushed `nav.cart`
132
+ comes back as `{"nav": {"cart": …}}`, because the SDK resolves nested
133
+ paths). Every CLI adapter is built on flat keys, so whole subtrees vanished:
134
+ `pull` wrote fewer keys than it pushed and `validate` reported the missing
135
+ ones forever. `getNamespace` now flattens the tree back to `dotted.key`
136
+ using the server's own leaf rule (a string, or an object carrying `text`, is
137
+ a value; any other object is a container).
138
+ - **`validate` no longer crashes on a malformed server value.** Reading
139
+ `.text` off a value that wasn't a ContentUnit threw
140
+ `Cannot read properties of undefined (reading 'trim')` and exit 1 — an
141
+ uncategorized crash on a normal run. The same normalization pass wraps bare
142
+ strings into `{ text, styles: {} }`, defaults a missing `styles`, and drops
143
+ a genuinely unusable leaf with a warning; `validate` treats an unusable
144
+ value as `empty` rather than assuming shape.
145
+ - **Clear error for keys with non-header-safe characters.** Pasting the
146
+ dashboard's truncated key display (`sk_eng_…last4`) used to surface as a
147
+ misleading "server could not be reached" network error — the U+2026
148
+ ellipsis can't be encoded into the `Authorization` header. The CLI now
149
+ rejects such keys at every resolution point (env, credentials file,
150
+ `login`) with exit 3, naming the offending character and the likely
151
+ truncated-copy cause, before any request is sent.
152
+ - **Security — codegen injection in `types generate`.** With `--server`, keys
153
+ and select-option labels are server-controlled; labels are now whitelisted
154
+ to `[A-Za-z0-9_]+` before reaching type position (non-conforming ones drop,
155
+ all-hostile falls back to `string`) and key escaping `\u`-escapes control
156
+ characters and JS line terminators, so no remote string can inject
157
+ declarations into the generated `.d.ts`.
158
+ - **Security — path containment for config-derived writes.** Pull targets,
159
+ multi-locale catalogs, snapshot outputs, and the generated `.d.ts` path must
160
+ resolve inside the project directory; `../` escapes and absolute paths are
161
+ rejected with exit 2 naming the offending path.
162
+
163
+ ## 0.2.1 — 2026-07-20
164
+
165
+ Security batch (PLAN items 19–23).
166
+
167
+ ### Changed
168
+
169
+ - **Base-URL trust boundary.** The default credential now only flows to
170
+ `*.lionrapid.com` and localhost. Any other `baseUrl` (self-hosted or a
171
+ hostile committed `lionrapid.yml`) requires a server-scoped credentials
172
+ entry — otherwise the CLI exits 3 with a remediation hint. This closes the
173
+ key-exfiltration path where a malicious repo config pointed `push` at an
174
+ attacker host. (The hinted `lionrapid login --server` command ships in a
175
+ later release; until then add the server-scoped entry manually.)
176
+ - **Redirects fail fast.** The API client pins `redirect: 'error'`; a 3xx is
177
+ treated as the server's answer, not a transient error — no retries.
178
+
179
+ ### Fixed
180
+
181
+ - **Prototype pollution on `pull`.** `__proto__`/`constructor`/`prototype`
182
+ path segments in server-supplied keys are skipped with a warning instead of
183
+ being materialized into nested objects.
184
+ - **Android XML escaping.** Server-controlled keys/values are attribute-escaped
185
+ at emit, and parse entity-decodes `name` attributes so escaped keys
186
+ round-trip.
187
+
188
+ ### Removed
189
+
190
+ - Sourcemaps are no longer published to npm (`!dist/**/*.map`).
191
+
192
+ ## 0.2.0 — 2026-07-19
193
+
194
+ File-sync hardening release (no changelog entry was written at release time;
195
+ see the git log for the Phase E hardening batch).
196
+
6
197
  ## 0.1.2 — 2026-06-26
7
198
 
8
199
  Hardening from end-to-end validation against the live backend.
package/README.md CHANGED
@@ -47,6 +47,35 @@ files:
47
47
  - { format: json, source: app/en.json, target: app/{locale}.json } # → namespace: app
48
48
  ```
49
49
 
50
+ **Glob entries:** `source` may be a glob (`*`/`?`); `target` and `namespace`
51
+ must then carry a `{basename}` token, replaced per matched file with its
52
+ extension-less name. One stanza covers a whole directory of per-page (or
53
+ per-feature) catalogs, each in its own namespace:
54
+
55
+ ```yaml
56
+ files:
57
+ - format: json
58
+ source: locales/en/*.json # locales/en/home.json, pricing.json, …
59
+ target: locales/{locale}/{basename}.json
60
+ namespace: '{basename}' # → namespaces home, pricing, …
61
+ ```
62
+
63
+ Globs expand once at config load (sorted, so behavior is deterministic) —
64
+ `push`/`pull`/`validate` see one concrete entry per matched file, and `doctor`
65
+ reports the expanded count. A glob that matches nothing prints a warning
66
+ instead of silently doing nothing; a glob entry without `{basename}` in _both_
67
+ `target` and `namespace` is rejected as ambiguous.
68
+
69
+ **Flat output:** JSON/YAML targets are written nested by default (a pushed
70
+ `hero.title` comes back as `{"hero": {"title": …}}`). Set `flat: true` on a
71
+ `files` entry to keep dotted keys literal on `pull`, so a flat-keyed file
72
+ round-trips flat for flat-keyed consumers (CMS imports, webhook payloads):
73
+
74
+ ```yaml
75
+ files:
76
+ - { format: json, source: locales/en.json, target: locales/{locale}.json, flat: true }
77
+ ```
78
+
50
79
  ## Reliability & automation
51
80
 
52
81
  Built for CI and AI agents — every networked command is resilient and scriptable.
@@ -61,9 +90,15 @@ Built for CI and AI agents — every networked command is resilient and scriptab
61
90
  - **Exit codes** (stable, gate CI on them): `0` ok · `1` unexpected · `2`
62
91
  validation/config · `3` auth · `4` network/5xx · `5` partial.
63
92
  - **Automatic retries**: transient failures (network error, `5xx`, `429`) are
64
- retried with exponential backoff + jitter; a `429` honors `Retry-After`. Tune
65
- with `LIONRAPID_RETRIES` (default `3`; `0` disables). Writes are safe to retry —
66
- the integration endpoints upsert by composite key.
93
+ retried with exponential backoff + jitter. A `429` honors `Retry-After` when
94
+ the server sends it; when it doesn't, the CLI waits a rate-limit-window 30 s
95
+ instead of jittering, so a many-namespace push rides out a per-minute limit.
96
+ Tune with `LIONRAPID_RETRIES` (default `3`; `0` disables). Writes are safe to
97
+ retry — the integration endpoints upsert by composite key.
98
+ - **Partial progress** (`push`, `pull`): one failing namespace or file doesn't
99
+ zero the run — the rest complete, failures are listed per file (and as
100
+ `error` fields under `--json`), and the exit code is `5` (partial).
101
+ Re-running retries just the failures; pushes upsert, so that's always safe.
67
102
  - **Concurrent `pull`**: namespace fetches fan out with bounded concurrency
68
103
  (`LIONRAPID_CONCURRENCY`, default `6`) and show interactive progress on a TTY.
69
104
  Output stays deterministic — only the network fan-out is parallelized.
@@ -36,6 +36,16 @@ function decodeInner(raw) {
36
36
  return c; // \' → ', \" → ", \\ → \
37
37
  });
38
38
  }
39
+ /** CLDR plural quantities Android accepts in `<item quantity="…">`. */
40
+ const CLDR_QUANTITIES = new Set(['zero', 'one', 'two', 'few', 'many', 'other']);
41
+ /** Escape a server-controlled string for an XML attribute value (mirrors xliff). */
42
+ function encodeAttr(s) {
43
+ return s
44
+ .replace(/&/g, '&amp;')
45
+ .replace(/</g, '&lt;')
46
+ .replace(/>/g, '&gt;')
47
+ .replace(/"/g, '&quot;');
48
+ }
39
49
  function encodeInner(text, hasStyles) {
40
50
  let s = text.replace(/&(?!(amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)/g, '&amp;');
41
51
  if (!hasStyles)
@@ -53,7 +63,7 @@ export class AndroidXmlAdapter {
53
63
  continue;
54
64
  hits.push({
55
65
  index: m.index,
56
- entries: [{ key: name, value: toContentUnit(decodeInner(inner)) }],
66
+ entries: [{ key: decodeXmlEntities(name), value: toContentUnit(decodeInner(inner)) }],
57
67
  });
58
68
  }
59
69
  for (const m of content.matchAll(PLURALS_RE)) {
@@ -61,7 +71,10 @@ export class AndroidXmlAdapter {
61
71
  const entries = [];
62
72
  for (const im of body.matchAll(ITEM_RE)) {
63
73
  const [, quantity, inner] = im;
64
- entries.push({ key: `${name}[${quantity}]`, value: toContentUnit(decodeInner(inner)) });
74
+ entries.push({
75
+ key: `${decodeXmlEntities(name)}[${quantity}]`,
76
+ value: toContentUnit(decodeInner(inner)),
77
+ });
65
78
  }
66
79
  hits.push({ index: m.index, entries });
67
80
  }
@@ -75,23 +88,33 @@ export class AndroidXmlAdapter {
75
88
  const pluralMatch = entry.key.match(PLURAL_KEY_RE);
76
89
  if (pluralMatch) {
77
90
  const base = pluralMatch[1];
78
- lines.push(` <plurals name="${base}">`);
91
+ const items = [];
79
92
  // Consume consecutive items of the same plural base.
80
93
  while (i < entries.length) {
81
94
  const current = entries[i];
82
95
  const cm = current.key.match(PLURAL_KEY_RE);
83
96
  if (!cm || cm[1] !== base)
84
97
  break;
98
+ const quantity = cm[2];
99
+ // Server-controlled quantities: only real CLDR categories may land
100
+ // in the attribute — drop anything else rather than emit it.
101
+ if (!CLDR_QUANTITIES.has(quantity)) {
102
+ console.error(`warning: dropped "${current.key}" — "${quantity}" is not a CLDR plural quantity`);
103
+ i += 1;
104
+ continue;
105
+ }
85
106
  const hasStyles = Object.keys(current.value.styles).length > 0;
86
- lines.push(` <item quantity="${cm[2]}">${encodeInner(fromContentUnit(current.value), hasStyles)}</item>`);
107
+ items.push(` <item quantity="${encodeAttr(quantity)}">${encodeInner(fromContentUnit(current.value), hasStyles)}</item>`);
87
108
  i += 1;
88
109
  }
89
110
  i -= 1; // outer loop will increment
90
- lines.push(' </plurals>');
111
+ if (items.length > 0) {
112
+ lines.push(` <plurals name="${encodeAttr(base)}">`, ...items, ' </plurals>');
113
+ }
91
114
  }
92
115
  else {
93
116
  const hasStyles = Object.keys(entry.value.styles).length > 0;
94
- lines.push(` <string name="${entry.key}">${encodeInner(fromContentUnit(entry.value), hasStyles)}</string>`);
117
+ lines.push(` <string name="${encodeAttr(entry.key)}">${encodeInner(fromContentUnit(entry.value), hasStyles)}</string>`);
95
118
  }
96
119
  }
97
120
  lines.push('</resources>', '');
@@ -3,10 +3,10 @@
3
3
  * node backends). Nested objects flatten to dot-notation keys (`home.cta`) and
4
4
  * unflatten back on serialize, preserving insertion order.
5
5
  */
6
- import type { FormatAdapter, TranslationEntry } from './types.js';
6
+ import type { FormatAdapter, SerializeOptions, TranslationEntry } from './types.js';
7
7
  export declare class JsonAdapter implements FormatAdapter {
8
8
  readonly format: "json";
9
9
  parse(content: string): TranslationEntry[];
10
- serialize(entries: TranslationEntry[]): string;
10
+ serialize(entries: TranslationEntry[], options?: SerializeOptions): string;
11
11
  }
12
12
  //# sourceMappingURL=json.d.ts.map
@@ -3,7 +3,7 @@
3
3
  * node backends). Nested objects flatten to dot-notation keys (`home.cta`) and
4
4
  * unflatten back on serialize, preserving insertion order.
5
5
  */
6
- import { flattenObject, unflattenEntries } from './nested.js';
6
+ import { flatEntriesObject, flattenObject, unflattenEntries } from './nested.js';
7
7
  export class JsonAdapter {
8
8
  format = 'json';
9
9
  parse(content) {
@@ -24,8 +24,9 @@ export class JsonAdapter {
24
24
  flattenObject(data, '', out);
25
25
  return out;
26
26
  }
27
- serialize(entries) {
28
- return `${JSON.stringify(unflattenEntries(entries), null, 2)}\n`;
27
+ serialize(entries, options) {
28
+ const tree = options?.flat ? flatEntriesObject(entries) : unflattenEntries(entries);
29
+ return `${JSON.stringify(tree, null, 2)}\n`;
29
30
  }
30
31
  }
31
32
  //# sourceMappingURL=json.js.map
@@ -5,5 +5,11 @@
5
5
  */
6
6
  import type { TranslationEntry } from './types.js';
7
7
  export declare function flattenObject(obj: Record<string, unknown>, prefix: string, out: TranslationEntry[]): void;
8
- export declare function unflattenEntries(entries: TranslationEntry[]): Record<string, unknown>;
8
+ /**
9
+ * Flat serialize: dotted keys stay literal (`"hero.title"`), nothing re-nests.
10
+ * Built with Object.fromEntries so a hostile key like `__proto__` lands as a
11
+ * plain own property instead of touching the prototype.
12
+ */
13
+ export declare function flatEntriesObject(entries: TranslationEntry[]): Record<string, unknown>;
14
+ export declare function unflattenEntries(entries: TranslationEntry[], onSkippedKey?: (key: string) => void): Record<string, unknown>;
9
15
  //# sourceMappingURL=nested.d.ts.map
@@ -18,14 +18,35 @@ export function flattenObject(obj, prefix, out) {
18
18
  }
19
19
  }
20
20
  }
21
- export function unflattenEntries(entries) {
21
+ /**
22
+ * Flat serialize: dotted keys stay literal (`"hero.title"`), nothing re-nests.
23
+ * Built with Object.fromEntries so a hostile key like `__proto__` lands as a
24
+ * plain own property instead of touching the prototype.
25
+ */
26
+ export function flatEntriesObject(entries) {
27
+ return Object.fromEntries(entries.map((e) => [e.key, fromContentUnit(e.value)]));
28
+ }
29
+ /** Path segments that would write onto the prototype chain instead of plain data. */
30
+ const UNSAFE_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
31
+ function warnSkippedKey(key) {
32
+ console.error(`warning: skipped key "${key}" — prototype-polluting path segment`);
33
+ }
34
+ export function unflattenEntries(entries, onSkippedKey = warnSkippedKey) {
22
35
  const root = {};
23
36
  for (const entry of entries) {
24
37
  const parts = entry.key.split('.');
38
+ // Keys come from the server bundle on `pull` — a poisoned key must be
39
+ // skipped (never throw: it must not brick the rest of the pull).
40
+ if (parts.some((p) => UNSAFE_SEGMENTS.has(p))) {
41
+ onSkippedKey(entry.key);
42
+ continue;
43
+ }
25
44
  let node = root;
26
45
  for (let i = 0; i < parts.length - 1; i += 1) {
27
46
  const part = parts[i];
28
- if (typeof node[part] !== 'object' || node[part] === null) {
47
+ if (!Object.prototype.hasOwnProperty.call(node, part) ||
48
+ typeof node[part] !== 'object' ||
49
+ node[part] === null) {
29
50
  node[part] = {};
30
51
  }
31
52
  node = node[part];
@@ -30,6 +30,8 @@ export interface SerializeOptions {
30
30
  locale?: string;
31
31
  /** Source locale, recorded by bilingual formats (e.g. XLIFF `source-language`). */
32
32
  sourceLocale?: string;
33
+ /** Keep dotted keys flat (JSON/YAML) instead of re-nesting them into objects. */
34
+ flat?: boolean;
33
35
  }
34
36
  export interface FormatAdapter {
35
37
  readonly format: FileFormat;
@@ -8,7 +8,7 @@
8
8
  * JSON adapter; this adapter assumes the Rails/locale-rooted shape.)
9
9
  */
10
10
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
11
- import { flattenObject, unflattenEntries } from './nested.js';
11
+ import { flatEntriesObject, flattenObject, unflattenEntries } from './nested.js';
12
12
  function isObject(v) {
13
13
  return v !== null && typeof v === 'object' && !Array.isArray(v);
14
14
  }
@@ -43,8 +43,8 @@ export class YamlAdapter {
43
43
  return out;
44
44
  }
45
45
  serialize(entries, options) {
46
- const nested = unflattenEntries(entries);
47
- const doc = options?.locale ? { [options.locale]: nested } : nested;
46
+ const tree = options?.flat ? flatEntriesObject(entries) : unflattenEntries(entries);
47
+ const doc = options?.locale ? { [options.locale]: tree } : tree;
48
48
  return stringifyYaml(doc);
49
49
  }
50
50
  }
@@ -41,9 +41,17 @@ export interface ApiClientOptions {
41
41
  retries?: number;
42
42
  /** Injectable delay (defaults to real `setTimeout`). Tests pass a no-op. */
43
43
  sleep?: (ms: number) => Promise<void>;
44
+ /**
45
+ * Per-attempt request timeout in ms (each retry gets a fresh budget).
46
+ * Default {@link DEFAULT_TIMEOUT_MS}. A hung connection must never block
47
+ * push/pull/doctor forever — poison for CI.
48
+ */
49
+ timeoutMs?: number;
44
50
  }
45
51
  /** Default number of retries for transient failures. */
46
52
  export declare const DEFAULT_RETRIES = 3;
53
+ /** Default per-attempt request timeout. */
54
+ export declare const DEFAULT_TIMEOUT_MS = 30000;
47
55
  /**
48
56
  * Parse a `Retry-After` header → ms, or null if absent/unparseable. Accepts the
49
57
  * delta-seconds form (`120`) and the HTTP-date form; clamps to
@@ -68,6 +76,19 @@ export declare class ApiError extends Error {
68
76
  export declare class NetworkError extends Error {
69
77
  constructor(method: string, path: string, cause: unknown);
70
78
  }
79
+ /**
80
+ * Coerce a namespace payload into flat `dotted.key` → {@link ContentUnit}.
81
+ *
82
+ * The endpoint takes flat keys on write but answers **nested** on read
83
+ * (`{"nav": {"cart": …}}` for a pushed `nav.cart`), because the SDK resolves
84
+ * nested paths. Every CLI adapter is built on flat dotted keys, so the tree
85
+ * has to be flattened back here or whole subtrees silently vanish. The leaf
86
+ * rule mirrors the server's: a string, or an object carrying `text`, is a
87
+ * value; any other object is a container to descend into. Bare strings are
88
+ * wrapped, a missing `styles` defaults to `{}`, and a genuinely unusable leaf
89
+ * is dropped with a warning rather than crashing the run.
90
+ */
91
+ export declare function normalizeTranslations(raw: unknown): Record<string, ContentUnit>;
71
92
  export declare class LionRapidApiClient {
72
93
  private readonly baseUrl;
73
94
  private readonly apiKey;
@@ -75,6 +96,7 @@ export declare class LionRapidApiClient {
75
96
  private readonly fetchImpl;
76
97
  private readonly retries;
77
98
  private readonly sleep;
99
+ private readonly timeoutMs;
78
100
  constructor(opts: ApiClientOptions);
79
101
  /** True when this client holds an editor token and can publish (mark-ready). */
80
102
  get canPublish(): boolean;
@@ -82,6 +104,16 @@ export declare class LionRapidApiClient {
82
104
  ping(): Promise<{
83
105
  isPmAdmin: boolean;
84
106
  }>;
107
+ /**
108
+ * The project's dashboard-configured locales (for `init` prefill).
109
+ * GET /api/integrations/project-config — flat `{ defaultLocale, languages }`;
110
+ * `languages` includes the default locale. Malformed bodies normalize to
111
+ * empty values rather than throwing.
112
+ */
113
+ getProjectConfig(): Promise<{
114
+ defaultLocale: string;
115
+ languages: string[];
116
+ }>;
85
117
  /**
86
118
  * Register source-row text for `sourceLocale`. Mirrors what the edge does on
87
119
  * first visit when it encounters a missing key. POST /api/integrations/sync/batch.