@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.
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `login` / `logout` — manage `~/.lionrapid/credentials`, the JSON map
3
+ * (`{ "default": "sk_…", "<server-url>": "sk_…" }`) that `resolveApiKey`
4
+ * reads. The key is verified against the server BEFORE persisting (a
5
+ * config:read probe, so publishable pk_ keys work too) — a failed
6
+ * verification writes nothing. Directory is 0700, file 0600.
7
+ *
8
+ * `--server` writes a server-scoped entry (the per-host opt-in that
9
+ * `resolveApiKey` demands for non-LionRapid hosts); bare `login` writes
10
+ * `default`. The key itself is never echoed or included in any envelope.
11
+ */
12
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
13
+ import { dirname } from 'node:path';
14
+ import { createInterface } from 'node:readline';
15
+ import { ApiKeyError, assertKeyCharset, defaultCredentialsPath, findConfigPath, loadConfig, } from '../config.js';
16
+ import { LionRapidApiClient } from '../api-client.js';
17
+ export const DEFAULT_SERVER = 'https://api.lionrapid.com';
18
+ export async function runLogin(opts = {}) {
19
+ const scope = opts.server ? 'server' : 'default';
20
+ const server = normalizeServer(opts.server ?? repoServer(opts.cwd) ?? DEFAULT_SERVER);
21
+ const key = (opts.key ?? (await (opts.promptKey ?? promptHidden)())).trim();
22
+ if (!key)
23
+ throw new ApiKeyError('No API key provided.');
24
+ assertKeyCharset(key);
25
+ // Verify before persisting — a bad key must never land in the file. The
26
+ // probe is project-config (`config:read`), which every key class carries —
27
+ // publishable pk_ keys included; the old editor/workflow ping needed
28
+ // `targets:write` and wrongly rejected pk_ logins.
29
+ const client = new LionRapidApiClient({
30
+ baseUrl: server,
31
+ apiKey: key,
32
+ fetchImpl: opts.fetchImpl,
33
+ });
34
+ await client.getProjectConfig();
35
+ const path = opts.credentialsPath ?? defaultCredentialsPath();
36
+ const creds = readCreds(path);
37
+ creds[scope === 'server' ? server : 'default'] = key;
38
+ writeCreds(path, creds);
39
+ return { server, scope, credentialsPath: path };
40
+ }
41
+ export function runLogout(opts = {}) {
42
+ const scope = opts.server ? 'server' : 'default';
43
+ const entry = opts.server ? normalizeServer(opts.server) : 'default';
44
+ const path = opts.credentialsPath ?? defaultCredentialsPath();
45
+ if (!existsSync(path))
46
+ return { scope, removed: false, credentialsPath: path };
47
+ const creds = readCreds(path);
48
+ const removed = Object.prototype.hasOwnProperty.call(creds, entry);
49
+ if (removed) {
50
+ delete creds[entry];
51
+ if (Object.keys(creds).length === 0) {
52
+ rmSync(path);
53
+ }
54
+ else {
55
+ writeCreds(path, creds);
56
+ }
57
+ }
58
+ return { scope, removed, credentialsPath: path };
59
+ }
60
+ /** Match the `config.server` normalization so scoped lookups hit exactly. */
61
+ function normalizeServer(server) {
62
+ return server.replace(/\/$/, '');
63
+ }
64
+ /** The repo's configured server, when a valid lionrapid.yml is present. */
65
+ function repoServer(cwd) {
66
+ const path = findConfigPath(cwd);
67
+ if (!path)
68
+ return undefined;
69
+ try {
70
+ return loadConfig(path).server;
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ }
76
+ function readCreds(path) {
77
+ if (!existsSync(path))
78
+ return {};
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
82
+ }
83
+ catch (err) {
84
+ throw new ApiKeyError(`Could not parse credentials file ${path}: ${err instanceof Error ? err.message : String(err)}. Fix or delete it, then retry.`);
85
+ }
86
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
87
+ throw new ApiKeyError(`Credentials file ${path} is not a JSON object. Fix or delete it, then retry.`);
88
+ }
89
+ return parsed;
90
+ }
91
+ function writeCreds(path, creds) {
92
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
93
+ writeFileSync(path, `${JSON.stringify(creds, null, 2)}\n`, { mode: 0o600 });
94
+ // writeFileSync's mode only applies on create — enforce it on merges too.
95
+ chmodSync(path, 0o600);
96
+ }
97
+ /** Hidden interactive prompt (no echo) via node:readline — no new dependency. */
98
+ export function promptHidden(question = 'API key (input hidden): ') {
99
+ return new Promise((resolve) => {
100
+ const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
101
+ process.stderr.write(question);
102
+ rl._writeToOutput = () => { };
103
+ rl.question('', (answer) => {
104
+ rl.close();
105
+ process.stderr.write('\n');
106
+ resolve(answer);
107
+ });
108
+ });
109
+ }
110
+ //# sourceMappingURL=login.js.map
@@ -9,16 +9,25 @@
9
9
  */
10
10
  import type { LionRapidConfig } from '../config.js';
11
11
  import type { LionRapidApiClient } from '../api-client.js';
12
+ import { type TranslationEntry } from '../adapters/index.js';
12
13
  export interface PullOptions {
13
14
  cwd: string;
14
15
  /** Fetch + count what would be written, but write no files. */
15
16
  dryRun?: boolean;
17
+ /** Overwrite target files wholesale instead of merging (drops local-only keys). */
18
+ force?: boolean;
16
19
  /** Progress callback, invoked as each namespace fetch completes. */
17
20
  onProgress?: (done: number, total: number) => void;
18
21
  }
19
22
  export interface PullFileResult {
20
23
  target: string;
21
24
  count: number;
25
+ /** Merge diff vs the existing target (absent on fresh writes and --force). */
26
+ added?: number;
27
+ updated?: number;
28
+ keptLocal?: number;
29
+ /** Set when this file failed (fetch or write) — the file was not written. */
30
+ error?: string;
22
31
  }
23
32
  export interface PullLocaleResult {
24
33
  locale: string;
@@ -31,5 +40,18 @@ export interface PullResult {
31
40
  /** True when this was a dry run — no files were written. */
32
41
  dryRun?: boolean;
33
42
  }
43
+ /**
44
+ * Overlay fetched entries onto the existing local file: server wins per key,
45
+ * local-only entries survive, existing key order is preserved and new keys
46
+ * append. Local `comment`s survive even on overlaid keys — the server bundle
47
+ * has no comment channel, so replacing the entry would silently drop them.
48
+ * Exported for direct unit testing.
49
+ */
50
+ export declare function mergeEntries(local: TranslationEntry[], fetched: TranslationEntry[]): {
51
+ entries: TranslationEntry[];
52
+ added: number;
53
+ updated: number;
54
+ keptLocal: number;
55
+ };
34
56
  export declare function runPull(config: LionRapidConfig, client: LionRapidApiClient, opts: PullOptions): Promise<PullResult>;
35
57
  //# sourceMappingURL=pull.d.ts.map
@@ -10,7 +10,40 @@
10
10
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
11
  import { dirname, resolve } from 'node:path';
12
12
  import { getAdapter } from '../adapters/index.js';
13
- import { mapLimit, resolveConcurrency } from './context.js';
13
+ import { assertInside, mapLimit, resolveConcurrency } from './context.js';
14
+ /**
15
+ * Overlay fetched entries onto the existing local file: server wins per key,
16
+ * local-only entries survive, existing key order is preserved and new keys
17
+ * append. Local `comment`s survive even on overlaid keys — the server bundle
18
+ * has no comment channel, so replacing the entry would silently drop them.
19
+ * Exported for direct unit testing.
20
+ */
21
+ export function mergeEntries(local, fetched) {
22
+ const newByKey = new Map(fetched.map((e) => [e.key, e]));
23
+ const entries = [];
24
+ let updated = 0;
25
+ let keptLocal = 0;
26
+ for (const l of local) {
27
+ const f = newByKey.get(l.key);
28
+ if (!f) {
29
+ keptLocal += 1;
30
+ entries.push(l);
31
+ continue;
32
+ }
33
+ if (JSON.stringify(f.value) !== JSON.stringify(l.value))
34
+ updated += 1;
35
+ entries.push(l.comment !== undefined ? { ...f, comment: l.comment } : f);
36
+ newByKey.delete(l.key); // consumed — what remains is genuinely new
37
+ }
38
+ let added = 0;
39
+ for (const f of fetched) {
40
+ if (newByKey.has(f.key)) {
41
+ entries.push(f);
42
+ added += 1;
43
+ }
44
+ }
45
+ return { entries, added, updated, keptLocal };
46
+ }
14
47
  /** Union of source keys per namespace, for "pending" reporting. */
15
48
  function sourceKeysByNamespace(config, cwd) {
16
49
  const byNs = new Map();
@@ -31,16 +64,29 @@ export async function runPull(config, client, opts) {
31
64
  const sourceKeys = sourceKeysByNamespace(config, opts.cwd);
32
65
  const perLocaleFiles = config.files.filter((f) => !getAdapter(f.format).multiLocale);
33
66
  const multiFiles = config.files.filter((f) => getAdapter(f.format).multiLocale);
34
- // Run-wide namespace cache, keyed `${locale}:${namespace}`.
67
+ // Run-wide namespace cache, keyed `${locale}:${namespace}`. A failed fetch
68
+ // is cached as an error instead of aborting the run — one bad namespace
69
+ // must not zero out a 56-file pull (partial progress, exit 5).
35
70
  const nsCache = new Map();
71
+ const nsErrors = new Map();
72
+ let firstError;
36
73
  const fetchNs = async (locale, namespace) => {
37
74
  const cacheKey = `${locale}:${namespace}`;
38
75
  const hit = nsCache.get(cacheKey);
39
76
  if (hit)
40
77
  return hit;
41
- const bundle = await client.getNamespace(locale, namespace);
42
- nsCache.set(cacheKey, bundle.translations);
43
- return bundle.translations;
78
+ if (nsErrors.has(cacheKey))
79
+ return null;
80
+ try {
81
+ const bundle = await client.getNamespace(locale, namespace);
82
+ nsCache.set(cacheKey, bundle.translations);
83
+ return bundle.translations;
84
+ }
85
+ catch (err) {
86
+ firstError ??= err;
87
+ nsErrors.set(cacheKey, err instanceof Error ? err : new Error(String(err)));
88
+ return null;
89
+ }
44
90
  };
45
91
  // Pre-fetch every needed (locale, namespace) bundle concurrently so the slow
46
92
  // network fan-out runs in parallel; the write loops below then read the warm
@@ -75,30 +121,85 @@ export async function runPull(config, client, opts) {
75
121
  const adapter = getAdapter(file.format);
76
122
  const namespace = file.namespace ?? config.namespace;
77
123
  usedNamespaces.add(namespace);
78
- const entries = toEntries(await fetchNs(locale, namespace));
79
124
  const relPath = file.target.replace('{locale}', locale);
80
- // Don't clobber an existing target with an empty result (locked/pending).
81
- // Dry run computes counts but writes nothing.
82
- if (entries.length > 0 && !opts.dryRun) {
83
- const absPath = resolve(opts.cwd, relPath);
84
- mkdirSync(dirname(absPath), { recursive: true });
85
- writeFileSync(absPath, adapter.serialize(entries, { locale, sourceLocale: config.sourceLocale }));
125
+ const translations = await fetchNs(locale, namespace);
126
+ if (translations === null) {
127
+ fileResults.push({
128
+ target: relPath,
129
+ count: 0,
130
+ error: nsErrors.get(`${locale}:${namespace}`).message,
131
+ });
132
+ continue;
133
+ }
134
+ const fetched = toEntries(translations);
135
+ try {
136
+ const absPath = assertInside(opts.cwd, relPath);
137
+ // Default is a non-destructive merge with the existing target: server
138
+ // wins per key, local-only keys survive. `--force` restores the
139
+ // wholesale overwrite.
140
+ let toWrite = fetched;
141
+ let diff;
142
+ if (!opts.force && fetched.length > 0 && existsSync(absPath)) {
143
+ const merged = mergeEntries(adapter.parse(readFileSync(absPath, 'utf8')), fetched);
144
+ toWrite = merged.entries;
145
+ diff = { added: merged.added, updated: merged.updated, keptLocal: merged.keptLocal };
146
+ }
147
+ // Don't clobber an existing target with an empty result (locked/pending).
148
+ // Dry run computes counts but writes nothing.
149
+ if (fetched.length > 0 && !opts.dryRun) {
150
+ mkdirSync(dirname(absPath), { recursive: true });
151
+ writeFileSync(absPath, adapter.serialize(toWrite, {
152
+ locale,
153
+ sourceLocale: config.sourceLocale,
154
+ flat: file.flat,
155
+ }));
156
+ }
157
+ fileResults.push({ target: relPath, count: fetched.length, ...(diff ?? {}) });
158
+ }
159
+ catch (err) {
160
+ firstError ??= err;
161
+ fileResults.push({
162
+ target: relPath,
163
+ count: 0,
164
+ error: err instanceof Error ? err.message : String(err),
165
+ });
86
166
  }
87
- fileResults.push({ target: relPath, count: entries.length });
88
167
  }
89
168
  for (const file of multiFiles) {
90
169
  const adapter = getAdapter(file.format);
91
170
  const namespace = file.namespace ?? config.namespace;
92
171
  usedNamespaces.add(namespace);
93
- const entries = toEntries(await fetchNs(locale, namespace));
94
- if (entries.length > 0) {
95
- const merged = adapter.mergeLocale(catalogContent.get(file) ?? null, entries, locale);
96
- catalogContent.set(file, merged);
172
+ const translations = await fetchNs(locale, namespace);
173
+ if (translations === null) {
174
+ fileResults.push({
175
+ target: file.target,
176
+ count: 0,
177
+ error: nsErrors.get(`${locale}:${namespace}`).message,
178
+ });
179
+ continue;
180
+ }
181
+ const entries = toEntries(translations);
182
+ try {
183
+ if (entries.length > 0) {
184
+ const merged = adapter.mergeLocale(catalogContent.get(file) ?? null, entries, locale);
185
+ catalogContent.set(file, merged);
186
+ }
187
+ fileResults.push({ target: file.target, count: entries.length });
188
+ }
189
+ catch (err) {
190
+ firstError ??= err;
191
+ fileResults.push({
192
+ target: file.target,
193
+ count: 0,
194
+ error: err instanceof Error ? err.message : String(err),
195
+ });
97
196
  }
98
- fileResults.push({ target: file.target, count: entries.length });
99
197
  }
100
198
  let pending = 0;
101
199
  for (const namespace of usedNamespaces) {
200
+ // A failed namespace's state is unknown — don't report its keys pending.
201
+ if (nsErrors.has(`${locale}:${namespace}`))
202
+ continue;
102
203
  const have = new Set(Object.keys(nsCache.get(`${locale}:${namespace}`) ?? {}));
103
204
  for (const key of sourceKeys.get(namespace) ?? new Set()) {
104
205
  if (!have.has(key))
@@ -112,12 +213,30 @@ export async function runPull(config, client, opts) {
112
213
  for (const file of multiFiles) {
113
214
  const content = catalogContent.get(file);
114
215
  if (content != null) {
115
- const absPath = resolve(opts.cwd, file.target);
116
- mkdirSync(dirname(absPath), { recursive: true });
117
- writeFileSync(absPath, content);
216
+ try {
217
+ const absPath = assertInside(opts.cwd, file.target);
218
+ mkdirSync(dirname(absPath), { recursive: true });
219
+ writeFileSync(absPath, content);
220
+ }
221
+ catch (err) {
222
+ firstError ??= err;
223
+ const message = err instanceof Error ? err.message : String(err);
224
+ for (const loc of locales) {
225
+ for (const f of loc.files) {
226
+ if (f.target === file.target && !f.error)
227
+ f.error = message;
228
+ }
229
+ }
230
+ }
118
231
  }
119
232
  }
120
233
  }
234
+ // Partial progress is a result; total failure keeps today's error contract —
235
+ // rethrow the first error so the exit-code mapping is unchanged.
236
+ const all = locales.flatMap((l) => l.files);
237
+ if (all.length > 0 && all.every((f) => f.error) && firstError !== undefined) {
238
+ throw firstError;
239
+ }
121
240
  return { locales, ...(opts.dryRun ? { dryRun: true } : {}) };
122
241
  }
123
242
  //# sourceMappingURL=pull.js.map
@@ -23,6 +23,8 @@ export interface PushFileResult {
23
23
  file: string;
24
24
  count: number;
25
25
  skipped?: boolean;
26
+ /** Set when this file failed (parse or upload) — it was not (fully) pushed. */
27
+ error?: string;
26
28
  }
27
29
  export interface PushResult {
28
30
  mode: 'source' | 'target';
@@ -13,6 +13,8 @@ import { resolve } from 'node:path';
13
13
  import { ConfigError } from '../config.js';
14
14
  import { getAdapter } from '../adapters/index.js';
15
15
  import { chunk } from './context.js';
16
+ // The server's enforced ceiling is 1000 items per sync/targets batch (Pydantic
17
+ // `max_length` on the request model) — 500 stays comfortably under it.
16
18
  const BATCH = 500;
17
19
  export async function runPush(config, client, opts) {
18
20
  const mode = opts.target ? 'target' : 'source';
@@ -32,6 +34,7 @@ export async function runPush(config, client, opts) {
32
34
  const canPublish = mode === 'target' && opts.publish === true && client.canPublish;
33
35
  const publishSkipped = mode === 'target' && opts.publish === true && !client.canPublish;
34
36
  let pushed = 0;
37
+ let firstError;
35
38
  for (const file of config.files) {
36
39
  const adapter = getAdapter(file.format);
37
40
  const namespace = file.namespace ?? config.namespace;
@@ -41,27 +44,47 @@ export async function runPush(config, client, opts) {
41
44
  files.push({ file: relPath, count: 0, skipped: true });
42
45
  continue;
43
46
  }
44
- const parseLocale = mode === 'source' ? config.sourceLocale : opts.target;
45
- const entries = adapter.parse(readFileSync(absPath, 'utf8'), { locale: parseLocale });
46
- const items = entries.map((e) => ({ key: e.key, value: e.value }));
47
- // Dry run: parse + count only, never touch the network.
48
- if (!opts.dryRun) {
49
- for (const batch of chunk(items, BATCH)) {
50
- if (mode === 'source') {
51
- await client.syncBatch(config.sourceLocale, namespace, batch);
52
- }
53
- else {
54
- await client.targetsBatch(opts.target, namespace, batch);
55
- if (canPublish) {
56
- for (const it of batch) {
57
- await client.markReady(opts.target, namespace, it.key);
47
+ // One bad file/namespace must not zero out the rest of the push (a rate
48
+ // limit on a many-namespace site, one corrupt file). Record the failure
49
+ // and move on — sync/batch upserts by composite key, so re-running push
50
+ // retries just the failures (partial progress, exit 5).
51
+ try {
52
+ const parseLocale = mode === 'source' ? config.sourceLocale : opts.target;
53
+ const entries = adapter.parse(readFileSync(absPath, 'utf8'), { locale: parseLocale });
54
+ const items = entries.map((e) => ({ key: e.key, value: e.value }));
55
+ // Dry run: parse + count only, never touch the network.
56
+ if (!opts.dryRun) {
57
+ for (const batch of chunk(items, BATCH)) {
58
+ if (mode === 'source') {
59
+ await client.syncBatch(config.sourceLocale, namespace, batch);
60
+ }
61
+ else {
62
+ await client.targetsBatch(opts.target, namespace, batch);
63
+ if (canPublish) {
64
+ for (const it of batch) {
65
+ await client.markReady(opts.target, namespace, it.key);
66
+ }
58
67
  }
59
68
  }
60
69
  }
61
70
  }
71
+ pushed += items.length;
72
+ files.push({ file: relPath, count: items.length });
62
73
  }
63
- pushed += items.length;
64
- files.push({ file: relPath, count: items.length });
74
+ catch (err) {
75
+ firstError ??= err;
76
+ files.push({
77
+ file: relPath,
78
+ count: 0,
79
+ error: err instanceof Error ? err.message : String(err),
80
+ });
81
+ }
82
+ }
83
+ // Partial progress is a result; total failure keeps today's error contract —
84
+ // rethrow the first error so the exit-code mapping is unchanged.
85
+ const attempted = files.filter((f) => !f.skipped);
86
+ if (attempted.length > 0 && attempted.every((f) => f.error) && firstError !== undefined) {
87
+ throw firstError;
65
88
  }
66
89
  return {
67
90
  mode,
@@ -13,7 +13,8 @@
13
13
  * surface with no re-derivation.
14
14
  */
15
15
  import { mkdirSync, writeFileSync } from 'node:fs';
16
- import { dirname, resolve } from 'node:path';
16
+ import { dirname } from 'node:path';
17
+ import { assertInside } from './context.js';
17
18
  export const DEFAULT_SNAPSHOT_DIR = 'snapshots';
18
19
  export async function runSnapshot(config, client, opts) {
19
20
  const out = opts.out ?? DEFAULT_SNAPSHOT_DIR;
@@ -41,7 +42,7 @@ export async function runSnapshot(config, client, opts) {
41
42
  translations: bundle.translations, // raw — styles preserved
42
43
  };
43
44
  const rel = `${out}/${locale}.${namespace}.json`;
44
- const abs = resolve(opts.cwd, rel);
45
+ const abs = assertInside(opts.cwd, rel);
45
46
  mkdirSync(dirname(abs), { recursive: true });
46
47
  writeFileSync(abs, `${JSON.stringify(snapshot, null, 2)}\n`);
47
48
  files.push({ path: rel, locale, namespace, keys });
@@ -14,6 +14,7 @@ import { dirname, resolve } from 'node:path';
14
14
  import { getAdapter } from '../adapters/index.js';
15
15
  import { generateDts } from '../types-gen/generate.js';
16
16
  import { extractParams, IcuError } from '../types-gen/extract.js';
17
+ import { assertInside } from './context.js';
17
18
  const DEFAULT_OUTPUT = 'types/translations.d.ts';
18
19
  /** Map every source key to its ICU message text (first definition wins). */
19
20
  export function collectSourceKeyTexts(config, cwd) {
@@ -50,7 +51,7 @@ export async function collectServerKeyTexts(config, client) {
50
51
  function writeDts(keyTexts, config, opts) {
51
52
  const dts = generateDts(keyTexts, { strictIcu: opts.strictIcu });
52
53
  const output = opts.output ?? config.typesOutput ?? DEFAULT_OUTPUT;
53
- const abs = resolve(opts.cwd, output);
54
+ const abs = assertInside(opts.cwd, output);
54
55
  mkdirSync(dirname(abs), { recursive: true });
55
56
  writeFileSync(abs, dts);
56
57
  return { keys: keyTexts.size, output };
@@ -9,12 +9,13 @@
9
9
  */
10
10
  import type { LionRapidConfig } from '../config.js';
11
11
  import type { LionRapidApiClient } from '../api-client.js';
12
- export type ValidateIssueType = 'missing' | 'empty' | 'orphan';
12
+ export type ValidateIssueType = 'missing' | 'empty' | 'orphan' | 'locked';
13
13
  export interface ValidateIssue {
14
14
  locale: string;
15
15
  namespace: string;
16
16
  type: ValidateIssueType;
17
- key: string;
17
+ /** Absent for namespace-level issues (`locked`). */
18
+ key?: string;
18
19
  }
19
20
  export interface ValidateResult {
20
21
  ok: boolean;
@@ -23,6 +24,8 @@ export interface ValidateResult {
23
24
  }
24
25
  export interface ValidateOptions {
25
26
  cwd: string;
27
+ /** Treat locked (423) namespaces as a warning instead of a failure. */
28
+ allowLocked?: boolean;
26
29
  }
27
30
  export declare function runValidate(config: LionRapidConfig, client: LionRapidApiClient, opts: ValidateOptions): Promise<ValidateResult>;
28
31
  //# sourceMappingURL=validate.d.ts.map
@@ -29,6 +29,12 @@ export async function runValidate(config, client, opts) {
29
29
  for (const locale of config.targetLocales) {
30
30
  for (const [namespace, sourceKeys] of sourceByNs) {
31
31
  const bundle = await client.getNamespace(locale, namespace);
32
+ // A locked (423) namespace has nothing approved for delivery yet — one
33
+ // namespace-level issue, not N spurious `missing` per source key.
34
+ if (bundle.locked) {
35
+ issues.push({ locale, namespace, type: 'locked' });
36
+ continue;
37
+ }
32
38
  const translations = bundle.translations;
33
39
  const have = new Set(Object.keys(translations));
34
40
  for (const key of sourceKeys) {
@@ -36,13 +42,20 @@ export async function runValidate(config, client, opts) {
36
42
  issues.push({ locale, namespace, type: 'missing', key });
37
43
  }
38
44
  for (const [key, value] of Object.entries(translations)) {
39
- if (!value.text.trim())
45
+ // Defensive: a value that isn't a real ContentUnit counts as empty
46
+ // rather than crashing the run (the client normalizes these, but
47
+ // `validate` is also driven directly in tests and by embedders).
48
+ if (typeof value?.text !== 'string' || !value.text.trim()) {
40
49
  issues.push({ locale, namespace, type: 'empty', key });
50
+ }
41
51
  if (!sourceKeys.has(key))
42
52
  issues.push({ locale, namespace, type: 'orphan', key });
43
53
  }
44
54
  }
45
55
  }
46
- return { ok: issues.length === 0, sourceKeys: totalSourceKeys, issues };
56
+ // `locked` fails by default (nothing is deliverable); `allowLocked`
57
+ // downgrades it so brand-new namespaces don't block CI.
58
+ const blocking = opts.allowLocked ? issues.filter((i) => i.type !== 'locked') : issues;
59
+ return { ok: blocking.length === 0, sourceKeys: totalSourceKeys, issues };
47
60
  }
48
61
  //# sourceMappingURL=validate.js.map
package/dist/config.d.ts CHANGED
@@ -17,14 +17,29 @@ export type FileFormat = (typeof FILE_FORMATS)[number];
17
17
  * `target` path is one shared file and does not need a `{locale}` placeholder.
18
18
  */
19
19
  export declare const MULTI_LOCALE_FORMATS: ReadonlySet<FileFormat>;
20
- /** One source→target file mapping. `target` carries a `{locale}` placeholder. */
20
+ /**
21
+ * One source→target file mapping. `target` carries a `{locale}` placeholder.
22
+ *
23
+ * `source` may be a glob (`*`/`?`, e.g. `locales/en/*.json`); then `target`
24
+ * and `namespace` must carry a `{basename}` token, and the entry expands at
25
+ * config load ({@link expandGlobEntries}) into one concrete entry per matched
26
+ * file with `{basename}` replaced by the file's extension-less name.
27
+ */
21
28
  export interface FileEntry {
22
29
  format: FileFormat;
23
30
  source: string;
24
31
  target: string;
25
32
  /** Optional per-entry namespace override (defaults to the top-level namespace). */
26
33
  namespace?: string;
34
+ /**
35
+ * Write dotted keys flat on `pull` (JSON/YAML only): a pushed `hero.title`
36
+ * stays `"hero.title"` instead of re-nesting into `{hero: {title}}`, so
37
+ * flat-keyed consumers (CMS imports, webhook payloads) round-trip.
38
+ */
39
+ flat?: boolean;
27
40
  }
41
+ /** True when a `files[].source` is a glob pattern rather than a concrete path. */
42
+ export declare function isGlobSource(source: string): boolean;
28
43
  export interface LionRapidConfig {
29
44
  project: string;
30
45
  server: string;
@@ -60,8 +75,33 @@ export declare class ApiKeyError extends Error {
60
75
  export declare function findConfigPath(cwd?: string): string | null;
61
76
  /** Parse + validate a YAML config string into a typed config. */
62
77
  export declare function parseConfig(rawYaml: string): LionRapidConfig;
78
+ /**
79
+ * Expand glob `files` entries into one concrete entry per matched file: the
80
+ * match's extension-less basename substitutes the `{basename}` token in
81
+ * `target` and `namespace`. Non-glob entries pass through untouched; matches
82
+ * come back sorted (deterministic expansion). A glob matching nothing warns
83
+ * instead of silently vanishing — a typo'd pattern must not no-op a push.
84
+ *
85
+ * Called at config load (loadOfflineContext / doctor), so every command
86
+ * downstream only ever sees concrete entries.
87
+ */
88
+ export declare function expandGlobEntries(files: FileEntry[], cwd: string, warn?: (message: string) => void): FileEntry[];
63
89
  /** Read + parse + validate the config at `path`. */
64
90
  export declare function loadConfig(path: string): LionRapidConfig;
91
+ /** Where the CLI reads/writes credentials: `~/.lionrapid/credentials`. */
92
+ export declare function defaultCredentialsPath(): string;
93
+ /**
94
+ * Reject a key that can't travel in an HTTP header (fetch encodes headers as
95
+ * Latin-1 and throws an obscure ByteString error otherwise). The classic case
96
+ * is copying the dashboard's truncated key display, `sk_eng_…last4` — that
97
+ * ellipsis is U+2026, not part of the key.
98
+ */
99
+ export declare function assertKeyCharset(key: string): string;
100
+ /**
101
+ * Server URLs that have a scoped entry in the credentials file. Lets `init`
102
+ * default its server to the one you just logged into instead of asking again.
103
+ */
104
+ export declare function listCredentialServers(credentialsPath?: string): string[];
65
105
  export interface ResolveApiKeyOptions {
66
106
  /** Environment to read `LIONRAPID_API_KEY` from (defaults to process.env). */
67
107
  env?: NodeJS.ProcessEnv;