@lionrapid/cli 0.2.1 → 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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,163 @@
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
+
6
163
  ## 0.2.1 — 2026-07-20
7
164
 
8
165
  Security batch (PLAN items 19–23).
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.
@@ -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
+ /**
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>;
8
14
  export declare function unflattenEntries(entries: TranslationEntry[], onSkippedKey?: (key: string) => void): Record<string, unknown>;
9
15
  //# sourceMappingURL=nested.d.ts.map
@@ -18,6 +18,14 @@ export function flattenObject(obj, prefix, out) {
18
18
  }
19
19
  }
20
20
  }
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
+ }
21
29
  /** Path segments that would write onto the prototype chain instead of plain data. */
22
30
  const UNSAFE_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
23
31
  function warnSkippedKey(key) {
@@ -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.
@@ -10,11 +10,21 @@
10
10
  */
11
11
  /** Default number of retries for transient failures. */
12
12
  export const DEFAULT_RETRIES = 3;
13
+ /** Default per-attempt request timeout. */
14
+ export const DEFAULT_TIMEOUT_MS = 30_000;
13
15
  /** Base backoff in ms; grows exponentially per attempt, capped at {@link MAX_BACKOFF_MS}. */
14
16
  const BASE_BACKOFF_MS = 300;
15
17
  const MAX_BACKOFF_MS = 10_000;
16
18
  /** Cap on an honored `Retry-After` so a hostile/misconfigured header can't hang the CLI. */
17
19
  const MAX_RETRY_AFTER_MS = 30_000;
20
+ /**
21
+ * Wait for a 429 that carries no `Retry-After` header. tm-server's rate-limit
22
+ * responses send none, and its limits are per-minute windows — the jittered
23
+ * sub-second backoff below can never outlive one, so without this floor every
24
+ * 429 exhausted its retries and was effectively fatal (e.g. a push past the
25
+ * 30/min sync limit died at namespace 31).
26
+ */
27
+ const RATE_LIMIT_FALLBACK_MS = 30_000;
18
28
  /** Transient statuses worth retrying: rate-limit (429) and any 5xx. */
19
29
  function isRetryableStatus(status) {
20
30
  return status === 429 || status >= 500;
@@ -98,6 +108,49 @@ export class NetworkError extends Error {
98
108
  function compositeKey(locale, namespace, key) {
99
109
  return `${locale}:${namespace}:${key}`;
100
110
  }
111
+ /**
112
+ * Coerce a namespace payload into flat `dotted.key` → {@link ContentUnit}.
113
+ *
114
+ * The endpoint takes flat keys on write but answers **nested** on read
115
+ * (`{"nav": {"cart": …}}` for a pushed `nav.cart`), because the SDK resolves
116
+ * nested paths. Every CLI adapter is built on flat dotted keys, so the tree
117
+ * has to be flattened back here or whole subtrees silently vanish. The leaf
118
+ * rule mirrors the server's: a string, or an object carrying `text`, is a
119
+ * value; any other object is a container to descend into. Bare strings are
120
+ * wrapped, a missing `styles` defaults to `{}`, and a genuinely unusable leaf
121
+ * is dropped with a warning rather than crashing the run.
122
+ */
123
+ export function normalizeTranslations(raw) {
124
+ const out = {};
125
+ const walk = (node, prefix) => {
126
+ if (node === null || typeof node !== 'object') {
127
+ console.error(`warning: skipped key "${prefix}" — server returned no usable text`);
128
+ return;
129
+ }
130
+ for (const [key, value] of Object.entries(node)) {
131
+ const path = prefix ? `${prefix}.${key}` : key;
132
+ if (typeof value === 'string') {
133
+ out[path] = { text: value, styles: {} };
134
+ continue;
135
+ }
136
+ if (value !== null && typeof value === 'object') {
137
+ const unit = value;
138
+ if (typeof unit.text === 'string') {
139
+ out[path] = { text: unit.text, styles: (unit.styles ?? {}) };
140
+ }
141
+ else {
142
+ walk(value, path); // a container, not a value
143
+ }
144
+ continue;
145
+ }
146
+ console.error(`warning: skipped key "${path}" — server returned no usable text`);
147
+ }
148
+ };
149
+ if (raw === null || typeof raw !== 'object')
150
+ return {};
151
+ walk(raw, '');
152
+ return out;
153
+ }
101
154
  export class LionRapidApiClient {
102
155
  baseUrl;
103
156
  apiKey;
@@ -105,6 +158,7 @@ export class LionRapidApiClient {
105
158
  fetchImpl;
106
159
  retries;
107
160
  sleep;
161
+ timeoutMs;
108
162
  constructor(opts) {
109
163
  this.baseUrl = opts.baseUrl.replace(/\/$/, '');
110
164
  this.apiKey = opts.apiKey;
@@ -113,6 +167,7 @@ export class LionRapidApiClient {
113
167
  this.retries = Math.max(0, opts.retries ?? DEFAULT_RETRIES);
114
168
  this.sleep =
115
169
  opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
170
+ this.timeoutMs = Math.max(1, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
116
171
  }
117
172
  /** True when this client holds an editor token and can publish (mark-ready). */
118
173
  get canPublish() {
@@ -123,6 +178,21 @@ export class LionRapidApiClient {
123
178
  const res = (await this.request('GET', '/api/integrations/editor/workflow'));
124
179
  return { isPmAdmin: !!res?.is_pm_admin };
125
180
  }
181
+ /**
182
+ * The project's dashboard-configured locales (for `init` prefill).
183
+ * GET /api/integrations/project-config — flat `{ defaultLocale, languages }`;
184
+ * `languages` includes the default locale. Malformed bodies normalize to
185
+ * empty values rather than throwing.
186
+ */
187
+ async getProjectConfig() {
188
+ const res = (await this.request('GET', '/api/integrations/project-config'));
189
+ return {
190
+ defaultLocale: typeof res?.defaultLocale === 'string' ? res.defaultLocale : '',
191
+ languages: Array.isArray(res?.languages)
192
+ ? res.languages.filter((l) => typeof l === 'string')
193
+ : [],
194
+ };
195
+ }
126
196
  /**
127
197
  * Register source-row text for `sourceLocale`. Mirrors what the edge does on
128
198
  * first visit when it encounters a missing key. POST /api/integrations/sync/batch.
@@ -187,7 +257,7 @@ export class LionRapidApiClient {
187
257
  }
188
258
  const data = (await res.json());
189
259
  return {
190
- translations: data.translations ?? {},
260
+ translations: normalizeTranslations(data.translations),
191
261
  version: data.meta?.version != null ? String(data.meta.version) : undefined,
192
262
  notModified: false,
193
263
  locked: false,
@@ -231,11 +301,17 @@ export class LionRapidApiClient {
231
301
  try {
232
302
  // `redirect: 'error'` pins every request to the configured host: a 3xx
233
303
  // fails loudly instead of relying on the runtime to strip the
234
- // Authorization header on a cross-origin redirect.
235
- res = await this.fetchImpl(`${this.baseUrl}${path}`, { ...init, redirect: 'error' });
304
+ // Authorization header on a cross-origin redirect. The timeout signal
305
+ // is per-attempt, so every retry gets a fresh budget.
306
+ res = await this.fetchImpl(`${this.baseUrl}${path}`, {
307
+ ...init,
308
+ redirect: 'error',
309
+ signal: AbortSignal.timeout(this.timeoutMs),
310
+ });
236
311
  }
237
312
  catch (err) {
238
- netErr = new NetworkError(method, path, err);
313
+ const isTimeout = err instanceof Error && err.name === 'TimeoutError';
314
+ netErr = new NetworkError(method, path, isTimeout ? new Error(`timed out after ${this.timeoutMs}ms`, { cause: err }) : err);
239
315
  // A refused redirect is the server's answer, not a transient failure.
240
316
  if (isRedirectFailure(err))
241
317
  throw netErr;
@@ -261,8 +337,11 @@ export class LionRapidApiClient {
261
337
  return res;
262
338
  throw netErr;
263
339
  }
264
- const retryAfter = res?.status === 429 ? parseRetryAfter(res.headers.get('retry-after'), Date.now()) : null;
265
- const delay = retryAfter ?? backoffMs(attempt, Math.random);
340
+ // A 429 is a rate-limit window, not a blip: honor `Retry-After` when the
341
+ // server sent one, else wait window-sized instead of jittering.
342
+ const delay = res?.status === 429
343
+ ? (parseRetryAfter(res.headers.get('retry-after'), Date.now()) ?? RATE_LIMIT_FALLBACK_MS)
344
+ : backoffMs(attempt, Math.random);
266
345
  await this.sleep(delay);
267
346
  }
268
347
  }
package/dist/cli.d.ts CHANGED
@@ -4,8 +4,7 @@
4
4
  * PO) with the LionRapid backend.
5
5
  *
6
6
  * This file wires up the command surface. Each command's behaviour lives in
7
- * `src/commands/*`; commands not yet implemented print a clear "not implemented
8
- * yet" notice and exit non-zero so scripts never silently succeed.
7
+ * `src/commands/*`.
9
8
  */
10
9
  import { Command } from 'commander';
11
10
  /**