@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.
package/dist/cli.js CHANGED
@@ -4,20 +4,23 @@
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
  import { readFileSync, realpathSync } from 'node:fs';
12
- import { dirname, join } from 'node:path';
11
+ import { basename, dirname, join } from 'node:path';
12
+ import { createInterface } from 'node:readline';
13
13
  import { fileURLToPath, pathToFileURL } from 'node:url';
14
- import { ConfigError, ApiKeyError } from './config.js';
15
- import { ApiError, NetworkError } from './api-client.js';
14
+ import { ConfigError, ApiKeyError, resolveApiKey, listCredentialServers, findConfigPath, loadConfig, } from './config.js';
15
+ import { ApiError, NetworkError, LionRapidApiClient } from './api-client.js';
16
16
  import { ExitCode, exitCodeForError } from './exit-codes.js';
17
17
  import { emitJson, errorEnvelope } from './output.js';
18
+ import { defaultJobHistoryPath, recordJobs, } from './job-history.js';
18
19
  import { loadContext, loadOfflineContext } from './commands/context.js';
19
- import { runInit } from './commands/init.js';
20
+ import { runInit, fetchLocaleDefaults, detectLocalesOnDisk } from './commands/init.js';
21
+ import { runLogin, runLogout, promptHidden, DEFAULT_SERVER } from './commands/login.js';
20
22
  import { runPush } from './commands/push.js';
23
+ import { runStatus, explainStatus } from './commands/status.js';
21
24
  import { runPull } from './commands/pull.js';
22
25
  import { runValidate } from './commands/validate.js';
23
26
  import { runTypesGenerate, runTypesGenerateServer, runTypesCheck } from './commands/types.js';
@@ -57,11 +60,167 @@ function fail(err, ctx = {}) {
57
60
  else {
58
61
  console.error(`✖ ${err instanceof Error ? err.message : String(err)}`);
59
62
  }
63
+ // Anything still true despite the failure — e.g. batches that were accepted
64
+ // before it. Printed after the error so the cause reads first.
65
+ for (const note of ctx.notes ?? [])
66
+ console.error(note);
60
67
  if (ctx.json) {
61
68
  process.stdout.write(`${JSON.stringify(errorEnvelope(ctx.command ?? 'lionrapid', err), null, 2)}\n`);
62
69
  }
63
70
  process.exit(exitCodeForError(err));
64
71
  }
72
+ /**
73
+ * Save this push's receipts to `~/.lionrapid/jobs.json`. Called on the success
74
+ * path AND from push's catch: a batch that was accepted before a later failure
75
+ * is still queued on the server, and its id is the only way to find out how it
76
+ * ended.
77
+ */
78
+ function saveQueuedJobs(jobs, ctx) {
79
+ const pushedAt = new Date().toISOString();
80
+ const entries = jobs.map((job) => ({
81
+ id: job.id,
82
+ server: ctx.config.server,
83
+ project: ctx.config.project,
84
+ namespace: job.namespace,
85
+ locale: ctx.config.sourceLocale,
86
+ files: [job.file],
87
+ keys: job.keys,
88
+ pushedAt,
89
+ status: job.status,
90
+ }));
91
+ recordJobs(entries);
92
+ return entries;
93
+ }
94
+ /** `2026-08-05 14:21:07` in local time, or the raw value if it isn't a date. */
95
+ function when(iso) {
96
+ if (!iso)
97
+ return '—';
98
+ const t = Date.parse(iso);
99
+ if (Number.isNaN(t))
100
+ return iso;
101
+ const d = new Date(t);
102
+ const pad = (n) => String(n).padStart(2, '0');
103
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
104
+ }
105
+ /** "128 key(s) from locales/en.json → app" — what a job is carrying. */
106
+ function jobPayload(view) {
107
+ const h = view.history;
108
+ if (!h)
109
+ return 'pushed from another machine or checkout';
110
+ return `${h.keys} key(s) from ${h.files.join(', ')} → ${h.namespace}`;
111
+ }
112
+ /** The detail view for `lionrapid status <job-id>`. */
113
+ function printJobDetail(view, out) {
114
+ const { icon, label, detail } = explainStatus(view);
115
+ out(`${icon} job ${view.id} — ${label}`);
116
+ out(` what ${jobPayload(view)}`);
117
+ if (view.history)
118
+ out(` pushed ${when(view.history.pushedAt)}`);
119
+ out(` verdict ${detail}`);
120
+ const job = view.job;
121
+ if (job?.deadLetterReason)
122
+ out(` reason ${job.deadLetterReason}`);
123
+ else if (view.history?.deadLetterReason)
124
+ out(` reason ${view.history.deadLetterReason}`);
125
+ const error = job?.error ?? view.history?.error;
126
+ if (error)
127
+ out(` error ${error}`);
128
+ if (job && job.retryCount > 0) {
129
+ out(` attempts ${job.retryCount} of ${job.maxRetries}`);
130
+ for (const e of job.retryErrors)
131
+ out(` · ${e}`);
132
+ }
133
+ if (job?.nextRetryAt)
134
+ out(` next try ${when(job.nextRetryAt)}`);
135
+ if (job?.completedAt)
136
+ out(` finished ${when(job.completedAt)}`);
137
+ // Never let a remembered status read as a live one.
138
+ if (view.source === 'local') {
139
+ out(` ⚠ last known on this machine${view.history?.checkedAt ? ` (checked ${when(view.history.checkedAt)})` : ''} — not confirmed with the server just now`);
140
+ }
141
+ else if (view.source === 'local-final') {
142
+ out(' · answered from this machine\'s record — the state is final, so no server call was needed');
143
+ }
144
+ if (label === 'dead-lettered') {
145
+ out(' next: fix the cause, then re-run `lionrapid push` — pushes upsert, so re-running is safe.');
146
+ }
147
+ else if (label === 'queued' || label === 'processing' || label === 'retrying') {
148
+ out(` next: \`lionrapid status ${view.id}\` again in a moment.`);
149
+ }
150
+ }
151
+ /** The listing for bare `lionrapid status` / `lionrapid status list`. */
152
+ function printJobList(res, out) {
153
+ out(`· ${res.jobs.length} job(s) pushed from this machine, newest first:`);
154
+ for (const view of res.jobs) {
155
+ const { icon, label } = explainStatus(view);
156
+ const h = view.history;
157
+ const where = h ? `${h.files.join(', ')} → ${h.namespace} · ${h.keys} key(s)` : '';
158
+ const stale = view.source === 'local' ? ' (last known)' : '';
159
+ out(` ${icon} ${view.id} ${label.padEnd(14)}${where} · ${when(h?.pushedAt)}${stale}`);
160
+ }
161
+ out(' next: `lionrapid status <job-id>` for the full story on one of them.');
162
+ }
163
+ /** Human rendering of a status result. Machine callers use `--json`. */
164
+ function printStatus(res, out) {
165
+ if (res.notFound) {
166
+ out('✖ no such job — the server does not know that id, and this machine has no record of it.');
167
+ out(' Finished jobs are kept about an hour server-side; older ones survive only in');
168
+ out(` ${res.historyPath}.`);
169
+ if (res.offline)
170
+ out(` (the server could not be asked: ${res.offline})`);
171
+ return;
172
+ }
173
+ if (res.mode === 'job') {
174
+ const view = res.jobs[0];
175
+ if (view)
176
+ printJobDetail(view, out);
177
+ return;
178
+ }
179
+ if (res.jobs.length > 0) {
180
+ printJobList(res, out);
181
+ if (res.offline) {
182
+ out(` ⚠ the server could not be reached (${res.offline}) — every line above is last known.`);
183
+ }
184
+ return;
185
+ }
186
+ // Nothing local: say so plainly, then show whatever the server will tell us.
187
+ out(`· no pushes recorded on this machine (${res.historyPath})`);
188
+ if (res.queue) {
189
+ const counts = Object.entries(res.queue.statusCounts)
190
+ .map(([k, v]) => `${v} ${k}`)
191
+ .join(', ');
192
+ out(` server queue: depth ${res.queue.queueDepth}${counts ? ` · ${counts}` : ''} · worker ${res.queue.workerRunning ? 'running' : 'NOT running'}`);
193
+ }
194
+ if (res.deadLettered && res.deadLettered.length > 0) {
195
+ out(` ✖ dead-letter queue: ${res.deadLettered.length} job(s) the backend gave up on:`);
196
+ for (const job of res.deadLettered) {
197
+ out(` ${job.jobId} ${job.deadLetterReason ?? job.error ?? 'no reason given'}`);
198
+ }
199
+ }
200
+ else if (res.deadLettered) {
201
+ out(' ✓ dead-letter queue is empty — nothing was dropped.');
202
+ }
203
+ if (res.queueUnavailable)
204
+ out(` · queue view unavailable: ${res.queueUnavailable}`);
205
+ if (res.offline)
206
+ out(` · the server could not be reached: ${res.offline}`);
207
+ out(' next: run `lionrapid push`, then `lionrapid status`.');
208
+ }
209
+ /**
210
+ * One-line interactive prompt on stderr with a default. Non-TTY (CI, pipes)
211
+ * never blocks — it returns the default immediately.
212
+ */
213
+ function askText(question, def) {
214
+ if (!process.stdin.isTTY)
215
+ return Promise.resolve(def);
216
+ return new Promise((resolve) => {
217
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
218
+ rl.question(`${question} [${def}]: `, (answer) => {
219
+ rl.close();
220
+ resolve(answer.trim() || def);
221
+ });
222
+ });
223
+ }
65
224
  /**
66
225
  * Build the commander program. Exported (rather than parsed inline) so tests
67
226
  * can construct and inspect the program without executing it.
@@ -71,7 +230,14 @@ export function buildProgram() {
71
230
  program
72
231
  .name('lionrapid')
73
232
  .description('Sync translation files (iOS, Android, Flutter, JSON, YAML, PO) with the LionRapid backend')
74
- .version(readVersion(), '-v, --version', 'output the current version');
233
+ .version(readVersion(), '-v, --version', 'output the current version')
234
+ .option('--timeout <ms>', 'per-attempt request timeout in ms (default 30000)')
235
+ // Global flag → env, so loadContext picks it up uniformly in every command.
236
+ .hook('preAction', (thisCommand) => {
237
+ const timeout = thisCommand.opts().timeout;
238
+ if (timeout)
239
+ process.env['LIONRAPID_TIMEOUT'] = timeout;
240
+ });
75
241
  program
76
242
  .command('init')
77
243
  .description('Scaffold a lionrapid.yml config in the current project')
@@ -79,24 +245,190 @@ export function buildProgram() {
79
245
  .option('--server <url>', 'API server URL')
80
246
  .option('--source <locale>', 'source locale')
81
247
  .option('--targets <list>', 'comma-separated target locales')
248
+ .option('--key <key>', 'API key used to prefill locales from the project config')
82
249
  .option('--force', 'overwrite an existing lionrapid.yml')
83
- .action((options) => {
250
+ .action(async (options) => {
84
251
  try {
252
+ // Reuse what's already known before asking: the repo's own config,
253
+ // then the server you logged into. Re-running `init` should not make
254
+ // you re-type things you've already told the CLI.
255
+ const existingServer = (() => {
256
+ const path = findConfigPath(process.cwd());
257
+ try {
258
+ return path ? loadConfig(path).server : undefined;
259
+ }
260
+ catch {
261
+ return undefined;
262
+ }
263
+ })();
264
+ const loggedIn = listCredentialServers();
265
+ const known = existingServer ?? (loggedIn.length === 1 ? loggedIn[0] : undefined);
266
+ // Only ask when there's genuinely nothing to go on. After a `login`,
267
+ // `lionrapid init` should be a single silent command.
268
+ const server = options.server ?? known ?? (await askText('Server URL', DEFAULT_SERVER));
269
+ // The project field is a local label (the API key selects the real
270
+ // project) — the directory name is a better default than a question.
271
+ const project = options.project ?? basename(process.cwd());
272
+ // Locale resolution ladder: explicit flags → the project's server
273
+ // config → locales already present in the repo → static placeholders.
274
+ // Every rung degrades quietly, so init still works fully offline.
275
+ let source = options.source;
276
+ let targets = options.targets?.split(',').map((s) => s.trim());
277
+ const notes = [];
278
+ if (!source || !targets) {
279
+ let key = options.key;
280
+ if (!key) {
281
+ try {
282
+ key = resolveApiKey({ server });
283
+ }
284
+ catch {
285
+ // no stored key for this server — prefill stays optional
286
+ }
287
+ }
288
+ if (!key && process.stdin.isTTY) {
289
+ key =
290
+ (await promptHidden('API key to prefill locales (Enter to skip): ')).trim() ||
291
+ undefined;
292
+ }
293
+ let serverStatus = 'no-key';
294
+ let serverTargets;
295
+ if (key) {
296
+ const client = new LionRapidApiClient({
297
+ baseUrl: server,
298
+ apiKey: key,
299
+ retries: 0,
300
+ timeoutMs: 5000,
301
+ });
302
+ const prefill = await fetchLocaleDefaults(client);
303
+ serverStatus = prefill.status;
304
+ if (prefill.status === 'ok') {
305
+ source ??= prefill.sourceLocale;
306
+ serverTargets = prefill.targetLocales;
307
+ targets ??= prefill.targetLocales;
308
+ }
309
+ else if (prefill.status === 'no-targets') {
310
+ source ??= prefill.sourceLocale; // the source locale IS real
311
+ }
312
+ }
313
+ const onDisk = detectLocalesOnDisk(process.cwd(), source ?? 'en');
314
+ if (serverStatus === 'ok' && serverTargets) {
315
+ notes.push(`locales from ${server}: ${source} → ${serverTargets.join(', ')}`);
316
+ // The project decides what actually gets delivered — a locale
317
+ // that exists only on disk is a real mismatch, worth saying out
318
+ // loud rather than silently merging into the scaffold.
319
+ const extra = onDisk.filter((l) => !serverTargets.includes(l));
320
+ if (extra.length > 0) {
321
+ notes.push(`note: ${extra.join(', ')} present in your files but not configured on the project —\n` +
322
+ ' add them in the dashboard, or pull will never fill those files.');
323
+ }
324
+ }
325
+ else if (onDisk.length > 0) {
326
+ targets ??= onDisk;
327
+ notes.push(serverStatus === 'no-targets'
328
+ ? `⚠ the project has no target languages configured (source: ${source}) — used ${onDisk.join(', ')} from the files in your repo.\n` +
329
+ ' Add them in the dashboard, then re-run `lionrapid init`.'
330
+ : `locales from your files: ${source ?? 'en'} → ${onDisk.join(', ')}`);
331
+ }
332
+ else if (serverStatus === 'no-targets') {
333
+ // Nothing invented: an empty targetLocales is honest, and the
334
+ // config stays invalid until the real languages exist.
335
+ notes.push(`⚠ the project has no target languages configured (source: ${source}), and none\n` +
336
+ ' were found in your files — targetLocales is empty and the config is not\n' +
337
+ ' usable yet. Add your languages in the dashboard, then re-run `lionrapid init`.');
338
+ }
339
+ else if (serverStatus === 'unavailable') {
340
+ notes.push('⚠ could not reach the project config — targetLocales left empty.\n' +
341
+ ' Fill it in by hand, or re-run `lionrapid init` once the server is reachable.');
342
+ }
343
+ else {
344
+ notes.push('⚠ no API key, so no project config to read — targetLocales left empty.\n' +
345
+ ' Run `lionrapid login`, then re-run `lionrapid init`.');
346
+ }
347
+ }
85
348
  const res = runInit(process.cwd(), {
86
- project: options.project,
87
- server: options.server,
88
- sourceLocale: options.source,
89
- targetLocales: options.targets?.split(',').map((s) => s.trim()),
349
+ project,
350
+ server,
351
+ sourceLocale: source,
352
+ targetLocales: targets,
90
353
  force: options.force,
91
354
  });
92
- console.log(`✓ wrote ${res.configPath}`);
355
+ if (res.created || options.force) {
356
+ console.log(`✓ wrote ${res.configPath}`);
357
+ }
358
+ else if (res.applied.length > 0) {
359
+ console.log(`✓ updated ${res.configPath}`);
360
+ for (const c of res.applied)
361
+ console.log(` + ${c.path}: ${c.to}`);
362
+ }
363
+ else {
364
+ console.log(`✓ ${res.configPath} is already up to date`);
365
+ }
93
366
  console.log(` detected: ${res.detected.stacks.join(', ')}`);
94
- console.log(' edit the project id + locales, then run `lionrapid push`.');
367
+ for (const note of notes)
368
+ console.log(` ${note}`);
369
+ if (res.conflicts.length > 0) {
370
+ // Never silently overwrite a value the developer set. Show the
371
+ // difference and let them resolve it in the file.
372
+ console.log(` ⚠ ${res.conflicts.length} value(s) differ from the project — left as-is:`);
373
+ for (const c of res.conflicts) {
374
+ console.log(` ${c.path}`);
375
+ console.log(` yours: ${c.from}`);
376
+ console.log(` project: ${c.to}`);
377
+ }
378
+ console.log(' Edit lionrapid.yml to resolve, or re-run with --force to take the');
379
+ console.log(' project values wholesale.');
380
+ }
381
+ const ready = res.conflicts.length === 0 && (targets?.length ?? 0) > 0;
382
+ console.log(ready
383
+ ? ' next: `lionrapid doctor` to check the setup, then `lionrapid push`.'
384
+ : ' next: fix the above in lionrapid.yml, then run `lionrapid doctor`.');
95
385
  }
96
386
  catch (err) {
97
387
  fail(err);
98
388
  }
99
389
  });
390
+ program
391
+ .command('login')
392
+ .description('Verify an API key against the server and save it to ~/.lionrapid/credentials')
393
+ .option('--server <url>', 'save the key scoped to this server URL (default: save as "default")')
394
+ .option('--key <key>', 'the API key (omit for a hidden interactive prompt)')
395
+ .option('--json', 'emit the result as JSON on stdout (prose to stderr)')
396
+ .action(async (options) => {
397
+ try {
398
+ const res = await runLogin({
399
+ server: options.server,
400
+ key: options.key,
401
+ cwd: process.cwd(),
402
+ });
403
+ const out = options.json ? console.error : console.log;
404
+ out(`✓ key verified against ${res.server}`);
405
+ out(` saved ${res.scope === 'server' ? `for ${res.server}` : 'as default'} → ${res.credentialsPath}`);
406
+ // Never echo the key — the envelope carries scope + server only.
407
+ if (options.json)
408
+ emitJson('login', true, { server: res.server, scope: res.scope });
409
+ }
410
+ catch (err) {
411
+ fail(err, { json: options.json, command: 'login' });
412
+ }
413
+ });
414
+ program
415
+ .command('logout')
416
+ .description('Remove a saved API key from ~/.lionrapid/credentials')
417
+ .option('--server <url>', 'remove the key scoped to this server URL (default: "default")')
418
+ .option('--json', 'emit the result as JSON on stdout (prose to stderr)')
419
+ .action((options) => {
420
+ try {
421
+ const res = runLogout({ server: options.server });
422
+ const out = options.json ? console.error : console.log;
423
+ const what = res.scope === 'server' ? `key for ${options.server}` : 'default key';
424
+ out(res.removed ? `✓ removed ${what}` : `· no ${what} was saved`);
425
+ if (options.json)
426
+ emitJson('logout', true, { scope: res.scope, removed: res.removed });
427
+ }
428
+ catch (err) {
429
+ fail(err, { json: options.json, command: 'logout' });
430
+ }
431
+ });
100
432
  program
101
433
  .command('push')
102
434
  .description('Upload source strings to the LionRapid backend')
@@ -106,29 +438,103 @@ export function buildProgram() {
106
438
  .option('--json', 'emit the result as JSON on stdout (prose to stderr)')
107
439
  .option('-c, --config <path>', 'path to lionrapid.yml')
108
440
  .action(async (options) => {
441
+ // Receipts are collected as they arrive, not from the result, so a
442
+ // total failure (which rethrows) still saves the ids of the batches
443
+ // that DID reach the server.
444
+ const queued = [];
445
+ let ctx;
109
446
  try {
110
- const ctx = loadContext({ config: options.config });
447
+ ctx = loadContext({ config: options.config });
111
448
  const res = await runPush(ctx.config, ctx.client, {
112
449
  cwd: ctx.cwd,
113
450
  target: options.target,
114
451
  publish: options.publish,
115
452
  dryRun: options.dryRun,
453
+ onJob: (job) => queued.push(job),
116
454
  });
117
455
  const out = options.json ? console.error : console.log;
118
456
  const tag = res.dryRun ? '[dry-run] would push' : '✓ pushed';
119
457
  out(`${tag} ${res.pushed} ${res.mode} string(s) across ${res.files.length} file(s)`);
120
458
  for (const f of res.files) {
459
+ if (f.error) {
460
+ out(` · ${f.file}: ✖ ${f.error}`);
461
+ continue;
462
+ }
121
463
  out(` ${f.skipped ? '· skipped (missing)' : '·'} ${f.file}: ${f.count}`);
122
464
  }
465
+ const failed = res.files.filter((f) => f.error);
466
+ if (failed.length > 0) {
467
+ out(`⚠ ${failed.length} file(s) failed — the rest were pushed. Re-run push to retry\n` +
468
+ ' the failures (already-pushed strings are simply upserted again).');
469
+ }
123
470
  if (res.publishSkipped) {
124
471
  out('⚠ uploaded as drafts but not published — publishing needs an editor session token.\n' +
125
472
  ' Set LIONRAPID_EDITOR_TOKEN, or publish in the dashboard.');
126
473
  }
474
+ // The whole point: "pushed" above is what we SENT. The server stores
475
+ // it in the background, so print what to watch and where it is saved.
476
+ if (res.jobs.length > 0) {
477
+ saveQueuedJobs(res.jobs, ctx);
478
+ out(`⧗ queued as ${res.jobs.length} background job(s) — the server stores these rows\n` +
479
+ ' asynchronously, so "pushed" means sent, not yet stored:');
480
+ for (const job of res.jobs) {
481
+ out(` ${job.id} ${job.file} (${job.keys} key(s))`);
482
+ }
483
+ out(` next: lionrapid status ${res.jobs[0].id}`);
484
+ out(` (saved to ${defaultJobHistoryPath()} — bare \`lionrapid status\` lists them)`);
485
+ }
486
+ else if (res.mode === 'source' && !res.dryRun && res.pushed > 0) {
487
+ // Accepted, but no ids came back — an older server or a proxy that
488
+ // ate the body. Say it rather than implying everything is confirmed.
489
+ out('⚠ the server accepted the batches but returned no job ids, so there is nothing\n' +
490
+ ' to poll — `lionrapid validate` is the way to confirm these rows landed.');
491
+ }
127
492
  if (options.json)
128
- emitJson('push', true, res);
493
+ emitJson('push', failed.length === 0, res);
494
+ if (failed.length > 0)
495
+ process.exit(ExitCode.PARTIAL);
129
496
  }
130
497
  catch (err) {
131
- fail(err, { json: options.json, command: 'push' });
498
+ // Batches accepted before the failure are still queued on the server.
499
+ // Save their ids and say so — the whole point is never being blind.
500
+ const notes = [];
501
+ if (ctx && queued.length > 0) {
502
+ saveQueuedJobs(queued, ctx);
503
+ notes.push(` · ${queued.length} batch(es) had already been queued before this failure:`, ...queued.map((job) => ` ${job.id} ${job.file} (${job.keys} key(s))`), ' Run `lionrapid status` to see how they ended.');
504
+ }
505
+ fail(err, { json: options.json, command: 'push', notes });
506
+ }
507
+ });
508
+ program
509
+ .command('status')
510
+ .argument('[job-id]', 'a job id from `push` (omit, or pass "list", for every recorded job)')
511
+ .description('Show what the server actually did with the strings `push` queued')
512
+ .option('--json', 'emit the result as JSON on stdout (prose to stderr)')
513
+ .option('-c, --config <path>', 'path to lionrapid.yml')
514
+ .action(async (jobId, options) => {
515
+ try {
516
+ // `status` must answer from anywhere: run outside a project, or with no
517
+ // saved key, it falls back to the on-disk history instead of failing.
518
+ let ctx;
519
+ try {
520
+ ctx = loadContext({ config: options.config });
521
+ }
522
+ catch {
523
+ ctx = undefined;
524
+ }
525
+ const res = await runStatus({
526
+ jobId,
527
+ client: ctx?.client,
528
+ server: ctx?.config.server,
529
+ });
530
+ printStatus(res, options.json ? console.error : console.log);
531
+ if (options.json)
532
+ emitJson('status', !res.notFound, res);
533
+ if (res.notFound)
534
+ process.exit(ExitCode.VALIDATION);
535
+ }
536
+ catch (err) {
537
+ fail(err, { json: options.json, command: 'status' });
132
538
  }
133
539
  });
134
540
  program
@@ -137,6 +543,7 @@ export function buildProgram() {
137
543
  .option('-c, --config <path>', 'path to lionrapid.yml')
138
544
  .option('--types', 'regenerate TypeScript types after pulling')
139
545
  .option('--dry-run', 'fetch + count what would be written, but write no files')
546
+ .option('--force', 'overwrite target files wholesale instead of merging (drops local-only keys)')
140
547
  .option('--json', 'emit the result as JSON on stdout (prose to stderr)')
141
548
  .action(async (options) => {
142
549
  try {
@@ -144,6 +551,7 @@ export function buildProgram() {
144
551
  const res = await runPull(ctx.config, ctx.client, {
145
552
  cwd: ctx.cwd,
146
553
  dryRun: options.dryRun,
554
+ force: options.force,
147
555
  onProgress: (done, total) => {
148
556
  // Interactive feedback only — never pollute CI logs or --json stdout.
149
557
  if (process.stderr.isTTY && total > 1) {
@@ -159,16 +567,29 @@ export function buildProgram() {
159
567
  const pending = loc.pending > 0 ? ` (${loc.pending} pending)` : '';
160
568
  out(`${mark} ${loc.locale}${pending}`);
161
569
  for (const f of loc.files) {
162
- out(` · ${f.target}: ${f.count}${res.dryRun ? ' (not written)' : ''}`);
570
+ if (f.error) {
571
+ out(` · ${f.target}: ✖ ${f.error}`);
572
+ continue;
573
+ }
574
+ const diff = f.added !== undefined
575
+ ? ` (+${f.added} new, ~${f.updated} updated, ${f.keptLocal} local kept)`
576
+ : '';
577
+ out(` · ${f.target}: ${f.count}${diff}${res.dryRun ? ' (not written)' : ''}`);
163
578
  }
164
579
  }
580
+ const failed = res.locales.flatMap((l) => l.files).filter((f) => f.error);
581
+ if (failed.length > 0) {
582
+ out(`⚠ ${failed.length} file(s) failed — the successful files were still written`);
583
+ }
165
584
  let types;
166
585
  if ((options.types || ctx.config.generateTypes) && !options.dryRun) {
167
586
  types = runTypesGenerate(ctx.config, { cwd: ctx.cwd });
168
587
  out(`✓ types: ${types.keys} key(s) → ${types.output}`);
169
588
  }
170
589
  if (options.json)
171
- emitJson('pull', true, { ...res, types });
590
+ emitJson('pull', failed.length === 0, { ...res, types });
591
+ if (failed.length > 0)
592
+ process.exit(ExitCode.PARTIAL);
172
593
  }
173
594
  catch (err) {
174
595
  fail(err, { json: options.json, command: 'pull' });
@@ -179,19 +600,23 @@ export function buildProgram() {
179
600
  .description('Check local files against the backend for missing/extra keys')
180
601
  .option('-c, --config <path>', 'path to lionrapid.yml')
181
602
  .option('--ci', 'exit non-zero if any issue is found')
603
+ .option('--allow-locked', 'treat locked (unapproved) namespaces as a warning, not a failure')
182
604
  .option('--json', 'emit the result as JSON on stdout (prose to stderr)')
183
605
  .action(async (options) => {
184
606
  try {
185
607
  const ctx = loadContext({ config: options.config });
186
- const res = await runValidate(ctx.config, ctx.client, { cwd: ctx.cwd });
608
+ const res = await runValidate(ctx.config, ctx.client, {
609
+ cwd: ctx.cwd,
610
+ allowLocked: options.allowLocked,
611
+ });
187
612
  const out = options.json ? console.error : console.log;
188
- if (res.ok) {
613
+ if (res.issues.length === 0) {
189
614
  out(`✓ all ${res.sourceKeys} source key(s) translated across target locales`);
190
615
  }
191
616
  else {
192
- out(`✖ ${res.issues.length} issue(s):`);
617
+ out(`${res.ok ? '⚠' : '✖'} ${res.issues.length} issue(s):`);
193
618
  for (const i of res.issues) {
194
- out(` [${i.type}] ${i.locale}/${i.namespace} ${i.key}`);
619
+ out(` [${i.type}] ${i.locale}/${i.namespace}${i.key ? ` ${i.key}` : ''}`);
195
620
  }
196
621
  }
197
622
  if (options.json)
@@ -22,8 +22,17 @@ export interface OfflineContext {
22
22
  export declare function loadOfflineContext(opts?: ContextOptions): OfflineContext;
23
23
  /** Load config + resolve the API key + build the client — for networked commands. */
24
24
  export declare function loadContext(opts?: ContextOptions): CliContext;
25
+ /** Read LIONRAPID_TIMEOUT (a positive integer, ms) if set; otherwise the client default. */
26
+ export declare function resolveTimeout(): number | undefined;
25
27
  /** Read LIONRAPID_RETRIES (a non-negative integer) if set; otherwise the client default. */
26
28
  export declare function resolveRetries(): number | undefined;
29
+ /**
30
+ * Resolve `rel` against `cwd`, refusing paths that escape it. Every
31
+ * config-derived file *write* goes through this — defense-in-depth so a
32
+ * `../`-authored target/output in lionrapid.yml can't write outside the
33
+ * project directory. Returns the absolute path.
34
+ */
35
+ export declare function assertInside(cwd: string, rel: string): string;
27
36
  export declare function chunk<T>(items: T[], size: number): T[][];
28
37
  /** Default parallel in-flight requests for fan-out work (pull). */
29
38
  export declare const DEFAULT_CONCURRENCY = 6;
@@ -3,7 +3,8 @@
3
3
  * build the API client. Kept separate from the commander wiring so command
4
4
  * logic can be driven with a fake client in tests.
5
5
  */
6
- import { findConfigPath, loadConfig, resolveApiKey, ConfigError, } from '../config.js';
6
+ import { isAbsolute, relative, resolve } from 'node:path';
7
+ import { expandGlobEntries, findConfigPath, loadConfig, resolveApiKey, ConfigError, } from '../config.js';
7
8
  import { LionRapidApiClient } from '../api-client.js';
8
9
  /** Load + validate config only (no API key) — for offline commands like `types`. */
9
10
  export function loadOfflineContext(opts = {}) {
@@ -12,7 +13,9 @@ export function loadOfflineContext(opts = {}) {
12
13
  if (!path) {
13
14
  throw new ConfigError('No lionrapid.yml found in this directory. Run `lionrapid init` to create one.');
14
15
  }
15
- return { config: loadConfig(path), cwd };
16
+ const config = loadConfig(path);
17
+ // Glob entries expand here, once — commands only ever see concrete files.
18
+ return { config: { ...config, files: expandGlobEntries(config.files, cwd) }, cwd };
16
19
  }
17
20
  /** Load config + resolve the API key + build the client — for networked commands. */
18
21
  export function loadContext(opts = {}) {
@@ -25,9 +28,18 @@ export function loadContext(opts = {}) {
25
28
  apiKey,
26
29
  editorToken,
27
30
  retries: resolveRetries(),
31
+ timeoutMs: resolveTimeout(),
28
32
  });
29
33
  return { config, client, cwd };
30
34
  }
35
+ /** Read LIONRAPID_TIMEOUT (a positive integer, ms) if set; otherwise the client default. */
36
+ export function resolveTimeout() {
37
+ const raw = process.env['LIONRAPID_TIMEOUT']?.trim();
38
+ if (!raw)
39
+ return undefined;
40
+ const n = Number(raw);
41
+ return Number.isInteger(n) && n > 0 ? n : undefined;
42
+ }
31
43
  /** Read LIONRAPID_RETRIES (a non-negative integer) if set; otherwise the client default. */
32
44
  export function resolveRetries() {
33
45
  const raw = process.env['LIONRAPID_RETRIES']?.trim();
@@ -36,6 +48,20 @@ export function resolveRetries() {
36
48
  const n = Number(raw);
37
49
  return Number.isInteger(n) && n >= 0 ? n : undefined;
38
50
  }
51
+ /**
52
+ * Resolve `rel` against `cwd`, refusing paths that escape it. Every
53
+ * config-derived file *write* goes through this — defense-in-depth so a
54
+ * `../`-authored target/output in lionrapid.yml can't write outside the
55
+ * project directory. Returns the absolute path.
56
+ */
57
+ export function assertInside(cwd, rel) {
58
+ const abs = resolve(cwd, rel);
59
+ const r = relative(cwd, abs);
60
+ if (r.startsWith('..') || isAbsolute(r)) {
61
+ throw new ConfigError(`Path escapes the project directory: ${rel}`);
62
+ }
63
+ return abs;
64
+ }
39
65
  export function chunk(items, size) {
40
66
  const out = [];
41
67
  for (let i = 0; i < items.length; i += size)