@lionrapid/cli 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,11 @@
5
5
  * rows via `sync/batch`; the server then queues machine translation for the
6
6
  * configured target locales.
7
7
  *
8
+ * `sync/batch` answers 202 Accepted — the rows are QUEUED, not yet stored. So
9
+ * every batch also yields job ids ({@link PushedJob}), which is the only thing
10
+ * that lets a caller tell "the backend stored this" from "the backend dropped
11
+ * it". Without them a push that dead-letters looks exactly like one that worked.
12
+ *
8
13
  * `--target <locale>` mode uploads an already-translated target file as drafts
9
14
  * via `targets/batch`, optionally publishing them with `--publish`.
10
15
  */
@@ -13,6 +18,8 @@ import { resolve } from 'node:path';
13
18
  import { ConfigError } from '../config.js';
14
19
  import { getAdapter } from '../adapters/index.js';
15
20
  import { chunk } from './context.js';
21
+ // The server's enforced ceiling is 1000 items per sync/targets batch (Pydantic
22
+ // `max_length` on the request model) — 500 stays comfortably under it.
16
23
  const BATCH = 500;
17
24
  export async function runPush(config, client, opts) {
18
25
  const mode = opts.target ? 'target' : 'source';
@@ -27,11 +34,13 @@ export async function runPush(config, client, opts) {
27
34
  'it in the dashboard before pushing target translations.');
28
35
  }
29
36
  const files = [];
37
+ const jobs = [];
30
38
  // Publishing (mark-ready) needs an editor token; if asked to publish without
31
39
  // one, we still upload the drafts and report that publishing was skipped.
32
40
  const canPublish = mode === 'target' && opts.publish === true && client.canPublish;
33
41
  const publishSkipped = mode === 'target' && opts.publish === true && !client.canPublish;
34
42
  let pushed = 0;
43
+ let firstError;
35
44
  for (const file of config.files) {
36
45
  const adapter = getAdapter(file.format);
37
46
  const namespace = file.namespace ?? config.namespace;
@@ -41,32 +50,68 @@ export async function runPush(config, client, opts) {
41
50
  files.push({ file: relPath, count: 0, skipped: true });
42
51
  continue;
43
52
  }
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);
53
+ // One bad file/namespace must not zero out the rest of the push (a rate
54
+ // limit on a many-namespace site, one corrupt file). Record the failure
55
+ // and move on — sync/batch upserts by composite key, so re-running push
56
+ // retries just the failures (partial progress, exit 5).
57
+ try {
58
+ const parseLocale = mode === 'source' ? config.sourceLocale : opts.target;
59
+ const entries = adapter.parse(readFileSync(absPath, 'utf8'), { locale: parseLocale });
60
+ const items = entries.map((e) => ({ key: e.key, value: e.value }));
61
+ // Dry run: parse + count only, never touch the network.
62
+ if (!opts.dryRun) {
63
+ for (const batch of chunk(items, BATCH)) {
64
+ if (mode === 'source') {
65
+ const queued = await client.syncBatch(config.sourceLocale, namespace, batch);
66
+ // Every id, not just job_id: a batch spanning several locales queues
67
+ // one job each, and the ones after the first would otherwise be
68
+ // unobservable. Reported as they happen so a later total failure
69
+ // cannot take the earlier receipts down with it.
70
+ for (const id of queued.job_ids) {
71
+ const job = {
72
+ id,
73
+ file: relPath,
74
+ namespace,
75
+ keys: batch.length,
76
+ status: queued.status || 'pending',
77
+ };
78
+ jobs.push(job);
79
+ opts.onJob?.(job);
80
+ }
81
+ }
82
+ else {
83
+ await client.targetsBatch(opts.target, namespace, batch);
84
+ if (canPublish) {
85
+ for (const it of batch) {
86
+ await client.markReady(opts.target, namespace, it.key);
87
+ }
58
88
  }
59
89
  }
60
90
  }
61
91
  }
92
+ pushed += items.length;
93
+ files.push({ file: relPath, count: items.length });
94
+ }
95
+ catch (err) {
96
+ firstError ??= err;
97
+ files.push({
98
+ file: relPath,
99
+ count: 0,
100
+ error: err instanceof Error ? err.message : String(err),
101
+ });
62
102
  }
63
- pushed += items.length;
64
- files.push({ file: relPath, count: items.length });
103
+ }
104
+ // Partial progress is a result; total failure keeps today's error contract —
105
+ // rethrow the first error so the exit-code mapping is unchanged.
106
+ const attempted = files.filter((f) => !f.skipped);
107
+ if (attempted.length > 0 && attempted.every((f) => f.error) && firstError !== undefined) {
108
+ throw firstError;
65
109
  }
66
110
  return {
67
111
  mode,
68
112
  pushed,
69
113
  files,
114
+ jobs,
70
115
  ...(publishSkipped ? { publishSkipped: true } : {}),
71
116
  ...(opts.dryRun ? { dryRun: true } : {}),
72
117
  };
@@ -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 });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `status` — answer the question a 202 leaves open: did the backend actually
3
+ * store what `push` sent?
4
+ *
5
+ * `push` reports what the CLI put on the wire. The server queues that work and
6
+ * answers 202, so a clean push is still compatible with the job failing later.
7
+ * This command closes the loop three ways:
8
+ *
9
+ * status <job-id> → GET /sync/jobs/{id}, the server's own verdict
10
+ * status [list] → every job this machine pushed, newest first
11
+ * (nothing local) → fall back to queue-health + the dead-letter queue
12
+ *
13
+ * Everything degrades. With no network, the local history is still shown and
14
+ * every line is marked as last-known rather than current; with no config or API
15
+ * key (running from some other directory) the disk view still works.
16
+ */
17
+ import { type LionRapidApiClient, type QueueHealth, type SyncJobStatus } from '../api-client.js';
18
+ import { type JobHistoryEntry } from '../job-history.js';
19
+ /** Where a line's status came from — the difference between "is" and "was". */
20
+ export type StatusSource =
21
+ /** Fetched from the server just now. */
22
+ 'server'
23
+ /** Read from `~/.lionrapid/jobs.json` — last known, not current. */
24
+ | 'local'
25
+ /** Terminal on disk: last known IS current, the server cannot change it. */
26
+ | 'local-final';
27
+ export interface JobView {
28
+ id: string;
29
+ status: string;
30
+ source: StatusSource;
31
+ /** What this machine recorded when it pushed (absent for a server-only job). */
32
+ history?: JobHistoryEntry;
33
+ /** The server's answer (absent when offline, or when answered from disk). */
34
+ job?: SyncJobStatus;
35
+ }
36
+ export interface StatusResult {
37
+ mode: 'job' | 'list';
38
+ jobs: JobView[];
39
+ /** Path of the history file the listing came from. */
40
+ historyPath: string;
41
+ /** Set when the server could not be reached — everything shown is last-known. */
42
+ offline?: string;
43
+ /** Queue fallback, only when there is nothing local to show. */
44
+ queue?: QueueHealth;
45
+ deadLettered?: SyncJobStatus[];
46
+ /** Why the queue fallback is missing (e.g. the key lacks `ops:queue`). */
47
+ queueUnavailable?: string;
48
+ /** `status <id>`: the id is known neither to the server nor to this machine. */
49
+ notFound?: boolean;
50
+ }
51
+ export interface StatusOptions {
52
+ /** A job id, `list`, or nothing (both of the latter mean "list"). */
53
+ jobId?: string;
54
+ /** Override the history file (tests; also keeps a push's receipts together). */
55
+ historyPath?: string;
56
+ /**
57
+ * The API client. Omitted when there is no config/API key here — the command
58
+ * then reports from disk only.
59
+ */
60
+ client?: LionRapidApiClient;
61
+ /** Limit the listing to jobs pushed to this server. */
62
+ server?: string;
63
+ }
64
+ /** True for the bare command and the explicit `list` form. */
65
+ export declare function isListRequest(jobId?: string): boolean;
66
+ export declare function runStatus(opts?: StatusOptions): Promise<StatusResult>;
67
+ /**
68
+ * One-line verdict for a job state, in the terms the incident was about: were
69
+ * these rows stored or not. Shared by the detail and list renderings.
70
+ */
71
+ export declare function explainStatus(view: JobView): {
72
+ icon: string;
73
+ label: string;
74
+ detail: string;
75
+ };
76
+ //# sourceMappingURL=status.d.ts.map
@@ -0,0 +1,221 @@
1
+ /**
2
+ * `status` — answer the question a 202 leaves open: did the backend actually
3
+ * store what `push` sent?
4
+ *
5
+ * `push` reports what the CLI put on the wire. The server queues that work and
6
+ * answers 202, so a clean push is still compatible with the job failing later.
7
+ * This command closes the loop three ways:
8
+ *
9
+ * status <job-id> → GET /sync/jobs/{id}, the server's own verdict
10
+ * status [list] → every job this machine pushed, newest first
11
+ * (nothing local) → fall back to queue-health + the dead-letter queue
12
+ *
13
+ * Everything degrades. With no network, the local history is still shown and
14
+ * every line is marked as last-known rather than current; with no config or API
15
+ * key (running from some other directory) the disk view still works.
16
+ */
17
+ import { ApiError, NetworkError, } from '../api-client.js';
18
+ import { defaultJobHistoryPath, isTerminalJobStatus, readJobHistory, updateJobStatuses, } from '../job-history.js';
19
+ import { mapLimit } from './context.js';
20
+ /**
21
+ * How many stale (non-terminal) jobs a bare `status` re-checks against the
22
+ * server. A listing is a glance, not a poll loop: the newest unresolved jobs
23
+ * are the ones anyone is actually waiting on, and the rest still print from
24
+ * disk — labelled as such, never silently stale.
25
+ */
26
+ const MAX_REFRESH = 20;
27
+ const REFRESH_CONCURRENCY = 4;
28
+ /** True for the bare command and the explicit `list` form. */
29
+ export function isListRequest(jobId) {
30
+ return jobId === undefined || jobId === 'list';
31
+ }
32
+ export async function runStatus(opts = {}) {
33
+ const historyPath = opts.historyPath ?? defaultJobHistoryPath();
34
+ const history = readJobHistory(historyPath);
35
+ return isListRequest(opts.jobId)
36
+ ? listJobs(history, historyPath, opts)
37
+ : showJob(opts.jobId, history, historyPath, opts);
38
+ }
39
+ /** `status <job-id>` — the server's verdict, or the local receipt if it can't answer. */
40
+ async function showJob(jobId, history, historyPath, opts) {
41
+ const recorded = history.find((e) => e.id === jobId);
42
+ const base = { mode: 'job', jobs: [], historyPath };
43
+ // Already final: the server cannot change its mind, and after ~1h it has
44
+ // forgotten the job entirely. Answer from the receipt and skip the call.
45
+ if (recorded && isTerminalJobStatus(recorded.status)) {
46
+ return { ...base, jobs: [localView(recorded)] };
47
+ }
48
+ if (!opts.client) {
49
+ // No client at all (no config or no key here). The receipt is all there is.
50
+ return recorded
51
+ ? { ...base, jobs: [localView(recorded)], offline: 'no API key or project config here' }
52
+ : { ...base, notFound: true, offline: 'no API key or project config here' };
53
+ }
54
+ try {
55
+ const job = await opts.client.getSyncJob(jobId);
56
+ if (recorded) {
57
+ persist(historyPath, [patchFor(job)]);
58
+ }
59
+ return { ...base, jobs: [{ id: jobId, status: job.status, source: 'server', history: recorded, job }] };
60
+ }
61
+ catch (err) {
62
+ // A 404 is an answer, not a failure: the id is unknown, or the job finished
63
+ // long enough ago that the server purged it (~1h retention). A job we
64
+ // already knew to be final never reaches here — it short-circuits above.
65
+ if (err instanceof ApiError && err.status === 404) {
66
+ if (recorded) {
67
+ persist(historyPath, [{ id: jobId, status: 'gone', checkedAt: new Date().toISOString() }]);
68
+ return { ...base, jobs: [{ ...localView({ ...recorded, status: 'gone' }), source: 'server' }] };
69
+ }
70
+ return { ...base, notFound: true };
71
+ }
72
+ if (!(err instanceof NetworkError))
73
+ throw err;
74
+ // Unreachable server: show the receipt, clearly marked as last-known.
75
+ if (!recorded)
76
+ return { ...base, notFound: true, offline: err.message };
77
+ return { ...base, jobs: [localView(recorded)], offline: err.message };
78
+ }
79
+ }
80
+ /** Bare `status` / `status list` — the local job log, refreshed where it matters. */
81
+ async function listJobs(history, historyPath, opts) {
82
+ const scoped = opts.server ? history.filter((e) => e.server === opts.server) : history;
83
+ if (scoped.length === 0) {
84
+ // Nothing local — ask the server what its queue looks like instead of
85
+ // shrugging. This is also the honest answer for "I pushed from another
86
+ // machine / another checkout".
87
+ return { mode: 'list', jobs: [], historyPath, ...(await queueFallback(opts.client)) };
88
+ }
89
+ const stale = scoped.filter((e) => !isTerminalJobStatus(e.status)).slice(0, MAX_REFRESH);
90
+ const views = new Map();
91
+ let offline;
92
+ if (opts.client && stale.length > 0) {
93
+ const client = opts.client;
94
+ const patches = [];
95
+ const refreshed = await mapLimit(stale, REFRESH_CONCURRENCY, async (entry) => {
96
+ try {
97
+ return await client.getSyncJob(entry.id);
98
+ }
99
+ catch (err) {
100
+ if (err instanceof ApiError && err.status === 404)
101
+ return 'gone';
102
+ if (err instanceof NetworkError) {
103
+ offline ??= err.message;
104
+ return undefined;
105
+ }
106
+ throw err;
107
+ }
108
+ });
109
+ refreshed.forEach((job, i) => {
110
+ const entry = stale[i];
111
+ if (job === undefined)
112
+ return;
113
+ if (job === 'gone') {
114
+ patches.push({ id: entry.id, status: 'gone', checkedAt: new Date().toISOString() });
115
+ views.set(entry.id, { id: entry.id, status: 'gone', source: 'server', history: entry });
116
+ return;
117
+ }
118
+ patches.push(patchFor(job));
119
+ views.set(entry.id, { id: entry.id, status: job.status, source: 'server', history: entry, job });
120
+ });
121
+ persist(historyPath, patches);
122
+ }
123
+ else if (!opts.client) {
124
+ offline = 'no API key or project config here';
125
+ }
126
+ const jobs = scoped.map((entry) => views.get(entry.id) ?? localView(entry));
127
+ return { mode: 'list', jobs, historyPath, ...(offline ? { offline } : {}) };
128
+ }
129
+ /** Queue-wide view for when this machine has no receipts to show. */
130
+ async function queueFallback(client) {
131
+ if (!client)
132
+ return { queueUnavailable: 'no API key or project config here' };
133
+ try {
134
+ const [queue, deadLettered] = await Promise.all([
135
+ client.getQueueHealth(),
136
+ client.getDeadLetterQueue(),
137
+ ]);
138
+ return { queue, deadLettered };
139
+ }
140
+ catch (err) {
141
+ if (err instanceof NetworkError)
142
+ return { offline: err.message };
143
+ // 403 is the common one: queue introspection needs the `ops:queue` scope,
144
+ // which publishable pk_ keys do not carry.
145
+ if (err instanceof ApiError) {
146
+ return {
147
+ queueUnavailable: err.status === 403
148
+ ? 'this API key may not read the queue (needs a master key)'
149
+ : `server returned ${err.status}`,
150
+ };
151
+ }
152
+ throw err;
153
+ }
154
+ }
155
+ function localView(entry) {
156
+ return {
157
+ id: entry.id,
158
+ status: entry.status,
159
+ source: isTerminalJobStatus(entry.status) ? 'local-final' : 'local',
160
+ history: entry,
161
+ };
162
+ }
163
+ function patchFor(job) {
164
+ return {
165
+ id: job.jobId,
166
+ status: job.status,
167
+ checkedAt: new Date().toISOString(),
168
+ error: job.error,
169
+ deadLetterReason: job.deadLetterReason,
170
+ };
171
+ }
172
+ /** History writes are best-effort — a read-only home must not fail `status`. */
173
+ function persist(historyPath, patches) {
174
+ if (patches.length > 0)
175
+ updateJobStatuses(patches, historyPath);
176
+ }
177
+ /**
178
+ * One-line verdict for a job state, in the terms the incident was about: were
179
+ * these rows stored or not. Shared by the detail and list renderings.
180
+ */
181
+ export function explainStatus(view) {
182
+ const dead = view.job?.isDeadLettered === true || view.status === 'failed';
183
+ switch (true) {
184
+ case dead:
185
+ return {
186
+ icon: '✖',
187
+ label: 'dead-lettered',
188
+ detail: 'the backend gave up — these rows were NOT stored',
189
+ };
190
+ case view.status === 'completed':
191
+ return { icon: '✓', label: 'completed', detail: storedDetail(view) };
192
+ case view.status === 'retry_pending':
193
+ return {
194
+ icon: '⚠',
195
+ label: 'retrying',
196
+ detail: 'a previous attempt failed; the backend will try again',
197
+ };
198
+ case view.status === 'processing':
199
+ return { icon: '·', label: 'processing', detail: 'the backend is storing these rows now' };
200
+ case view.status === 'pending':
201
+ return { icon: '·', label: 'queued', detail: 'accepted, not stored yet' };
202
+ case view.status === 'gone':
203
+ return {
204
+ icon: '?',
205
+ label: 'gone',
206
+ detail: 'the server no longer has this job — finished jobs are kept ~1h',
207
+ };
208
+ default:
209
+ return { icon: '?', label: view.status || 'unknown', detail: '' };
210
+ }
211
+ }
212
+ /** "128 key(s) stored (5 added, 3 updated)" when the server reported counts. */
213
+ function storedDetail(view) {
214
+ const result = view.job?.result;
215
+ const added = typeof result?.['keys_added'] === 'number' ? result['keys_added'] : undefined;
216
+ const updated = typeof result?.['keys_updated'] === 'number' ? result['keys_updated'] : undefined;
217
+ if (added === undefined && updated === undefined)
218
+ return 'the rows were stored';
219
+ return `stored — ${added ?? 0} added, ${updated ?? 0} updated`;
220
+ }
221
+ //# sourceMappingURL=status.js.map
@@ -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;