@lionrapid/cli 0.3.0 → 0.5.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
@@ -8,19 +8,21 @@
8
8
  */
9
9
  import { Command } from 'commander';
10
10
  import { readFileSync, realpathSync } from 'node:fs';
11
- import { basename, dirname, join } from 'node:path';
11
+ import { dirname, join } from 'node:path';
12
12
  import { createInterface } from 'node:readline';
13
13
  import { fileURLToPath, pathToFileURL } from 'node:url';
14
- import { ConfigError, ApiKeyError, resolveApiKey, listCredentialServers, findConfigPath, loadConfig, } from './config.js';
14
+ import { ConfigError, noTargetsNotice, ApiKeyError, keyCapability, resolveApiKey, listCredentialServers, findConfigPath, loadConfig, } from './config.js';
15
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
18
  import { defaultJobHistoryPath, recordJobs, } from './job-history.js';
19
19
  import { loadContext, loadOfflineContext } from './commands/context.js';
20
- import { runInit, fetchLocaleDefaults, detectLocalesOnDisk } from './commands/init.js';
21
- import { runLogin, runLogout, promptHidden, DEFAULT_SERVER } from './commands/login.js';
20
+ import { runInit, detectProject, fetchLocaleDefaults, detectLocalesOnDisk, ensureKey, summarizeSources, } from './commands/init.js';
21
+ import { runLogin, runLogout, DEFAULT_SERVER } from './commands/login.js';
22
22
  import { runPush } from './commands/push.js';
23
+ import { runWhoami } from './commands/whoami.js';
23
24
  import { runStatus, explainStatus } from './commands/status.js';
25
+ import { formatLocal } from './time.js';
24
26
  import { runPull } from './commands/pull.js';
25
27
  import { runValidate } from './commands/validate.js';
26
28
  import { runTypesGenerate, runTypesGenerateServer, runTypesCheck } from './commands/types.js';
@@ -80,7 +82,9 @@ function saveQueuedJobs(jobs, ctx) {
80
82
  const entries = jobs.map((job) => ({
81
83
  id: job.id,
82
84
  server: ctx.config.server,
83
- project: ctx.config.project,
85
+ // The config's free-text label is gone; only a projectId the server
86
+ // confirmed is worth recording, and it may legitimately be absent.
87
+ ...(ctx.config.projectId ? { projectId: ctx.config.projectId } : {}),
84
88
  namespace: job.namespace,
85
89
  locale: ctx.config.sourceLocale,
86
90
  files: [job.file],
@@ -91,16 +95,22 @@ function saveQueuedJobs(jobs, ctx) {
91
95
  recordJobs(entries);
92
96
  return entries;
93
97
  }
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())}`;
98
+ /**
99
+ * `2026-08-05 14:21:07` in local time. Naive server timestamps are read as
100
+ * UTC — see `time.ts`; a plain `Date.parse` here made a job look like it had
101
+ * finished three hours before the push that queued it.
102
+ */
103
+ const when = formatLocal;
104
+ /** Where a key came from, in the terms a user would use to change it. */
105
+ function describeKeySource(key) {
106
+ switch (key.source) {
107
+ case 'env':
108
+ return 'from LIONRAPID_API_KEY — overrides every saved scope';
109
+ case 'server-scoped':
110
+ return `saved for ${key.scope}`;
111
+ default:
112
+ return 'the default saved key';
113
+ }
104
114
  }
105
115
  /** "128 key(s) from locales/en.json → app" — what a job is carrying. */
106
116
  function jobPayload(view) {
@@ -142,7 +152,14 @@ function printJobDetail(view, out) {
142
152
  out(' · answered from this machine\'s record — the state is final, so no server call was needed');
143
153
  }
144
154
  if (label === 'dead-lettered') {
145
- out(' next: fix the cause, then re-run `lionrapid push` — pushes upsert, so re-running is safe.');
155
+ // This sentence has now been wrong twice, which is why it carries its own
156
+ // test. "Re-run push" broke when push became incremental (the lock records
157
+ // these rows as sent, so a plain re-push skips them), and "delete the
158
+ // lockfile" was a workaround for a file the user was never told they had.
159
+ // Both flags ask the SERVER what it holds, so neither trusts the lock.
160
+ out(' next: fix the cause, then `lionrapid push --backfill-missing` to send just the rows');
161
+ out(' the server is missing, or `lionrapid push --force` to resend everything.');
162
+ out(' A plain `push` would skip them — the lock recorded them as sent.');
146
163
  }
147
164
  else if (label === 'queued' || label === 'processing' || label === 'retrying') {
148
165
  out(` next: \`lionrapid status ${view.id}\` again in a moment.`);
@@ -206,9 +223,36 @@ function printStatus(res, out) {
206
223
  out(` · the server could not be reached: ${res.offline}`);
207
224
  out(' next: run `lionrapid push`, then `lionrapid status`.');
208
225
  }
226
+ /** Interactive picker: list the scopes and take a number or an exact name. */
227
+ async function askWhich(scopes) {
228
+ console.error('Which credential do you want to remove?');
229
+ scopes.forEach((s, i) => console.error(` ${i + 1}) ${s}`));
230
+ const answer = (await askText('Number (or blank to cancel)', '')).trim();
231
+ if (!answer)
232
+ return undefined;
233
+ const index = Number(answer);
234
+ if (Number.isInteger(index) && index >= 1 && index <= scopes.length)
235
+ return scopes[index - 1];
236
+ return scopes.includes(answer) ? answer : undefined;
237
+ }
238
+ /**
239
+ * Thrown when a non-interactive run needs an answer nobody can give.
240
+ *
241
+ * Silently accepting a default is how `https://api.lionrapid.com` — a host
242
+ * with no DNS record — got written into configs by CI and by agents: the
243
+ * prompt never appeared, so nobody ever declined it. A scaffold that names
244
+ * what it needs is recoverable; one that invents a value is not.
245
+ */
246
+ function requireInteractive(what, flag) {
247
+ throw new ConfigError(`Cannot ask for ${what} — this is not an interactive terminal.\n` +
248
+ ` Pass ${flag} explicitly (and set LIONRAPID_API_KEY if a key is needed).`);
249
+ }
209
250
  /**
210
251
  * One-line interactive prompt on stderr with a default. Non-TTY (CI, pipes)
211
252
  * never blocks — it returns the default immediately.
253
+ *
254
+ * Callers that must not accept an unasked default check `process.stdin.isTTY`
255
+ * themselves and call {@link requireInteractive}; see `init`.
212
256
  */
213
257
  function askText(question, def) {
214
258
  if (!process.stdin.isTTY)
@@ -241,7 +285,6 @@ export function buildProgram() {
241
285
  program
242
286
  .command('init')
243
287
  .description('Scaffold a lionrapid.yml config in the current project')
244
- .option('--project <id>', 'project id')
245
288
  .option('--server <url>', 'API server URL')
246
289
  .option('--source <locale>', 'source locale')
247
290
  .option('--targets <list>', 'comma-separated target locales')
@@ -265,10 +308,20 @@ export function buildProgram() {
265
308
  const known = existingServer ?? (loggedIn.length === 1 ? loggedIn[0] : undefined);
266
309
  // Only ask when there's genuinely nothing to go on. After a `login`,
267
310
  // `lionrapid init` should be a single silent command.
311
+ // Non-interactive with nothing to go on: refuse. `askText` would
312
+ // return DEFAULT_SERVER without ever showing the prompt, which is
313
+ // how a dead host reached a config file in the first place.
314
+ if (!options.server && !known && !process.stdin.isTTY) {
315
+ requireInteractive('a server URL', '--server');
316
+ }
268
317
  const server = options.server ?? known ?? (await askText('Server URL', DEFAULT_SERVER));
269
318
  // The project field is a local label (the API key selects the real
270
319
  // project) — the directory name is a better default than a question.
271
- const project = options.project ?? basename(process.cwd());
320
+ let signedIn = false;
321
+ let projectId;
322
+ // The namespaces the scaffold will declare — the only places a
323
+ // project id can be read from before a config exists.
324
+ const res0Namespaces = () => detectProject(process.cwd()).files.map((f) => f.namespace ?? 'app');
272
325
  // Locale resolution ladder: explicit flags → the project's server
273
326
  // config → locales already present in the repo → static placeholders.
274
327
  // Every rung degrades quietly, so init still works fully offline.
@@ -276,20 +329,21 @@ export function buildProgram() {
276
329
  let targets = options.targets?.split(',').map((s) => s.trim());
277
330
  const notes = [];
278
331
  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
- }
332
+ // ONE PASS: a key typed here is verified and SAVED, so the next
333
+ // command finds it. `init` used to prompt, use the key to prefill
334
+ // locales, and throw it away — you then typed the same key into
335
+ // `login` and ran `init` a second time.
336
+ const resolved = await ensureKey({
337
+ server,
338
+ key: options.key,
339
+ isTTY: Boolean(process.stdin.isTTY),
340
+ // Mirror login: an explicit --server scopes the entry, otherwise
341
+ // it is the default "my LionRapid account key".
342
+ scope: options.server ? 'server' : 'default',
343
+ });
344
+ const key = resolved.key;
345
+ if (resolved.signedIn)
346
+ signedIn = true;
293
347
  let serverStatus = 'no-key';
294
348
  let serverTargets;
295
349
  if (key) {
@@ -310,6 +364,30 @@ export function buildProgram() {
310
364
  source ??= prefill.sourceLocale; // the source locale IS real
311
365
  }
312
366
  }
367
+ // Capture the project this key resolves to, so the mismatch guard
368
+ // has something to check on every later push. Best-effort: the
369
+ // field is undeclared server-side, and a brand-new project has no
370
+ // rows to read it from.
371
+ if (key) {
372
+ const probe = new LionRapidApiClient({
373
+ baseUrl: server,
374
+ apiKey: key,
375
+ retries: 0,
376
+ timeoutMs: 5000,
377
+ });
378
+ for (const ns of new Set(res0Namespaces())) {
379
+ try {
380
+ const bundle = await probe.getNamespace(source ?? 'en', ns);
381
+ if (bundle.projectId) {
382
+ projectId = bundle.projectId;
383
+ break;
384
+ }
385
+ }
386
+ catch {
387
+ // next namespace
388
+ }
389
+ }
390
+ }
313
391
  const onDisk = detectLocalesOnDisk(process.cwd(), source ?? 'en');
314
392
  if (serverStatus === 'ok' && serverTargets) {
315
393
  notes.push(`locales from ${server}: ${source} → ${serverTargets.join(', ')}`);
@@ -346,7 +424,7 @@ export function buildProgram() {
346
424
  }
347
425
  }
348
426
  const res = runInit(process.cwd(), {
349
- project,
427
+ ...(projectId ? { projectId } : {}),
350
428
  server,
351
429
  sourceLocale: source,
352
430
  targetLocales: targets,
@@ -366,6 +444,14 @@ export function buildProgram() {
366
444
  console.log(` detected: ${res.detected.stacks.join(', ')}`);
367
445
  for (const note of notes)
368
446
  console.log(` ${note}`);
447
+ // A declared source with nothing behind it is the difference between
448
+ // `doctor: sources: 1 missing` being a puzzle and being obvious.
449
+ if (res.missingSources.length > 0) {
450
+ console.log(` ⚠ ${res.missingSources.length} declared source(s) do not exist on disk:`);
451
+ for (const source of res.missingSources)
452
+ console.log(` ${source}`);
453
+ console.log(' Remove the stale entry from lionrapid.yml, or create the file.');
454
+ }
369
455
  if (res.conflicts.length > 0) {
370
456
  // Never silently overwrite a value the developer set. Show the
371
457
  // difference and let them resolve it in the file.
@@ -378,9 +464,23 @@ export function buildProgram() {
378
464
  console.log(' Edit lionrapid.yml to resolve, or re-run with --force to take the');
379
465
  console.log(' project values wholesale.');
380
466
  }
467
+ // A key typed during this run is now saved — say so, or the user has
468
+ // no way to know they do not need `login`.
469
+ if (signedIn)
470
+ console.log(` ✓ signed in to ${server} — key saved for next time`);
471
+ if (projectId) {
472
+ console.log(` ✓ project ${projectId} — push will refuse if a key resolves elsewhere`);
473
+ }
474
+ // Close on a fact rather than a promise: parse what was just
475
+ // declared, so "wrote lionrapid.yml" comes with what it will push.
476
+ const summary = summarizeSources(process.cwd(), res.detected.files);
477
+ if (summary.files > 0) {
478
+ const arrow = targets && targets.length > 0 ? `, ${source ?? 'en'} → ${targets.join(', ')}` : '';
479
+ console.log(` ✓ ${summary.strings} string(s) across ${summary.namespaces} namespace(s) in ${summary.files} file(s)${arrow}`);
480
+ }
381
481
  const ready = res.conflicts.length === 0 && (targets?.length ?? 0) > 0;
382
482
  console.log(ready
383
- ? ' next: `lionrapid doctor` to check the setup, then `lionrapid push`.'
483
+ ? ' next: `lionrapid push --dry-run`, then `lionrapid push`.'
384
484
  : ' next: fix the above in lionrapid.yml, then run `lionrapid doctor`.');
385
485
  }
386
486
  catch (err) {
@@ -412,18 +512,84 @@ export function buildProgram() {
412
512
  }
413
513
  });
414
514
  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")')
515
+ .command('whoami')
516
+ .description('Show which API key this directory would use, and every saved scope')
418
517
  .option('--json', 'emit the result as JSON on stdout (prose to stderr)')
518
+ .option('-c, --config <path>', 'path to lionrapid.yml')
419
519
  .action((options) => {
420
520
  try {
421
- const res = runLogout({ server: options.server });
521
+ const res = runWhoami({ cwd: process.cwd(), config: options.config });
422
522
  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`);
523
+ // Four combinations, and the two mixed ones are the interesting ones:
524
+ // a key with no server (run outside a project) is still a real answer,
525
+ // and a server with no key is the failure worth naming.
526
+ if (res.server && res.key) {
527
+ out(`this directory → ${res.server} → ${res.key.masked} (${describeKeySource(res.key)})`);
528
+ out(` ${res.key.capability.summary}`);
529
+ }
530
+ else if (res.key) {
531
+ out(`${res.key.masked} (${describeKeySource(res.key)}) — but no server here yet`);
532
+ }
533
+ else if (res.server) {
534
+ out(`this directory → ${res.server}`);
535
+ out('✖ no key resolves for that server');
536
+ }
537
+ else {
538
+ out('✖ no server and no key resolve here');
539
+ }
540
+ if (res.problem)
541
+ for (const line of res.problem.split('\n'))
542
+ out(` ${line}`);
543
+ if (res.scopes.length > 0) {
544
+ out('');
545
+ out('saved scopes');
546
+ const width = Math.max(...res.scopes.map((s) => s.scope.length));
547
+ for (const s of res.scopes)
548
+ out(` · ${s.scope.padEnd(width)} ${s.masked}`);
549
+ }
550
+ else {
551
+ out('');
552
+ out('saved scopes: none — run `lionrapid login`');
553
+ }
425
554
  if (options.json)
426
- emitJson('logout', true, { scope: res.scope, removed: res.removed });
555
+ emitJson('whoami', res.key !== undefined, res);
556
+ }
557
+ catch (err) {
558
+ fail(err, { json: options.json, command: 'whoami' });
559
+ }
560
+ });
561
+ program
562
+ .command('logout')
563
+ .description('Remove a saved API key from ~/.lionrapid/credentials')
564
+ .option('--server <url>', 'remove the key scoped to this server URL')
565
+ .option('--all', 'remove every saved key and delete the credentials file')
566
+ .option('--json', 'emit the result as JSON on stdout (prose to stderr)')
567
+ .action(async (options) => {
568
+ try {
569
+ const res = await runLogout({
570
+ server: options.server,
571
+ all: options.all,
572
+ // Only offer a picker on a real terminal; otherwise `runLogout`
573
+ // refuses rather than guessing which credential to delete.
574
+ choose: process.stdin.isTTY ? askWhich : undefined,
575
+ });
576
+ const out = options.json ? console.error : console.log;
577
+ if (!res.removed) {
578
+ out('· nothing was removed');
579
+ }
580
+ else if (res.scope === 'all') {
581
+ out(`✓ removed all ${res.removedScopes} saved key(s) — ${res.credentialsPath} deleted`);
582
+ }
583
+ else {
584
+ out(`✓ removed the key for ${res.scope}`);
585
+ }
586
+ if (options.json) {
587
+ emitJson('logout', true, {
588
+ scope: res.scope,
589
+ removed: res.removed,
590
+ removedScopes: res.removedScopes,
591
+ });
592
+ }
427
593
  }
428
594
  catch (err) {
429
595
  fail(err, { json: options.json, command: 'logout' });
@@ -435,6 +601,8 @@ export function buildProgram() {
435
601
  .option('--target <locale>', 'push an already-translated target locale instead of the source')
436
602
  .option('--publish', 'in --target mode, mark pushed rows ready (public)')
437
603
  .option('--dry-run', 'parse + count what would be pushed, but make no network calls')
604
+ .option('--backfill-missing', 'send only the rows the server is missing (asks the server)')
605
+ .option('--force', 'ignore the lockfile and resend every row (saves overwritten server rows to .lionrapid/history/). Does NOT override server-side protection of human-committed rows')
438
606
  .option('--json', 'emit the result as JSON on stdout (prose to stderr)')
439
607
  .option('-c, --config <path>', 'path to lionrapid.yml')
440
608
  .action(async (options) => {
@@ -445,22 +613,53 @@ export function buildProgram() {
445
613
  let ctx;
446
614
  try {
447
615
  ctx = loadContext({ config: options.config });
616
+ // Say it BEFORE the request. A pk_ key pushes sources fine but
617
+ // `targets/batch` answers a bare 403 — found the hard way on
618
+ // 2026-08-07 while trying to publish a row.
619
+ if (options.target) {
620
+ const capability = keyCapability(resolveApiKey({ server: ctx.config.server }));
621
+ if (!capability.canWriteTargets) {
622
+ throw new ApiKeyError(`This key cannot write target translations — ${capability.summary}\n` +
623
+ ' `push --target` needs targets:write. Use a master (sk_) key:\n' +
624
+ ` lionrapid login --server ${ctx.config.server}`);
625
+ }
626
+ }
448
627
  const res = await runPush(ctx.config, ctx.client, {
449
628
  cwd: ctx.cwd,
450
629
  target: options.target,
451
630
  publish: options.publish,
452
631
  dryRun: options.dryRun,
453
632
  onJob: (job) => queued.push(job),
633
+ backfillMissing: options.backfillMissing,
634
+ force: options.force,
635
+ // Incremental: the lock lives at the project root, beside lionrapid.yml.
636
+ lockPath: ctx.cwd,
454
637
  });
455
638
  const out = options.json ? console.error : console.log;
639
+ const notice = noTargetsNotice(ctx.config);
640
+ if (notice)
641
+ out(notice);
456
642
  const tag = res.dryRun ? '[dry-run] would push' : '✓ pushed';
457
- out(`${tag} ${res.pushed} ${res.mode} string(s) across ${res.files.length} file(s)`);
643
+ // Name the unchanged count, or an incremental push that sends nothing
644
+ // reads as a failure. "0 pushed · 653 unchanged" is a success.
645
+ const unchanged = res.skipped > 0 ? ` · ${res.skipped} unchanged` : '';
646
+ out(`${tag} ${res.pushed} ${res.mode} string(s) across ${res.files.length} file(s)${unchanged}`);
647
+ // The diff is the whole value of a dry run: "2 to send" cannot tell
648
+ // you whether you are adding strings or overwriting existing ones.
649
+ if (res.projectCheck === 'unavailable') {
650
+ out(' · project check skipped — this server disclosed no project id');
651
+ }
652
+ if (res.diff && res.pushed > 0) {
653
+ out(` ${res.diff.added} new · ${res.diff.changed} changed · ${res.diff.unchanged} unchanged`);
654
+ }
458
655
  for (const f of res.files) {
459
656
  if (f.error) {
460
657
  out(` · ${f.file}: ✖ ${f.error}`);
461
658
  continue;
462
659
  }
463
- out(` ${f.skipped ? '· skipped (missing)' : '·'} ${f.file}: ${f.count}`);
660
+ const same = f.unchanged ? ` (${f.unchanged} unchanged)` : '';
661
+ const held = f.refused ? ` (${f.refused} not written)` : '';
662
+ out(` ${f.skipped ? '· skipped (missing)' : '·'} ${f.file}: ${f.count}${held}${same}`);
464
663
  }
465
664
  const failed = res.files.filter((f) => f.error);
466
665
  if (failed.length > 0) {
@@ -471,6 +670,25 @@ export function buildProgram() {
471
670
  out('⚠ uploaded as drafts but not published — publishing needs an editor session token.\n' +
472
671
  ' Set LIONRAPID_EDITOR_TOKEN, or publish in the dashboard.');
473
672
  }
673
+ // Rows the server declined to overwrite. Same voice as pull's
674
+ // kept-local warning: name the key and the reason, because a bare
675
+ // count tells you something happened without telling you what.
676
+ const refused = [
677
+ ...res.protected.map((r) => ({ ...r, kind: 'protected' })),
678
+ ...res.denied.map((r) => ({ ...r, kind: 'denied' })),
679
+ ];
680
+ if (refused.length > 0) {
681
+ out(`⚠ ${refused.length} row(s) were NOT overwritten — the server is protecting them:`);
682
+ const width = Math.max(...refused.map((r) => r.key.length));
683
+ for (const r of refused) {
684
+ const current = r.current_text ? ` (server has: "${r.current_text}")` : '';
685
+ out(` ${r.key.padEnd(width)} ${r.reason}${current}`);
686
+ }
687
+ // There is deliberately no override flag, so do not imply one.
688
+ // These are the paths that DO change a committed row.
689
+ out(' A human committed these. There is no CLI override — change them in the');
690
+ out(' Visual Editor, or use "Use for all" to apply one wording across the project.');
691
+ }
474
692
  // The whole point: "pushed" above is what we SENT. The server stores
475
693
  // it in the background, so print what to watch and where it is saved.
476
694
  if (res.jobs.length > 0) {
@@ -509,6 +727,7 @@ export function buildProgram() {
509
727
  .command('status')
510
728
  .argument('[job-id]', 'a job id from `push` (omit, or pass "list", for every recorded job)')
511
729
  .description('Show what the server actually did with the strings `push` queued')
730
+ .option('--refresh', 'ask the server again even for a job already recorded as finished')
512
731
  .option('--json', 'emit the result as JSON on stdout (prose to stderr)')
513
732
  .option('-c, --config <path>', 'path to lionrapid.yml')
514
733
  .action(async (jobId, options) => {
@@ -526,8 +745,14 @@ export function buildProgram() {
526
745
  jobId,
527
746
  client: ctx?.client,
528
747
  server: ctx?.config.server,
748
+ refresh: options.refresh,
529
749
  });
530
- printStatus(res, options.json ? console.error : console.log);
750
+ const out = options.json ? console.error : console.log;
751
+ printStatus(res, out);
752
+ if (res.retention === 'purged') {
753
+ out(' · the server no longer retains this job (finished jobs are kept ~1h) —');
754
+ out(' the recorded verdict above stands.');
755
+ }
531
756
  if (options.json)
532
757
  emitJson('status', !res.notFound, res);
533
758
  if (res.notFound)
@@ -562,6 +787,9 @@ export function buildProgram() {
562
787
  },
563
788
  });
564
789
  const out = options.json ? console.error : console.log;
790
+ const notice = noTargetsNotice(ctx.config);
791
+ if (notice)
792
+ out(notice);
565
793
  const mark = res.dryRun ? '[dry-run]' : '✓';
566
794
  for (const loc of res.locales) {
567
795
  const pending = loc.pending > 0 ? ` (${loc.pending} pending)` : '';
@@ -577,6 +805,17 @@ export function buildProgram() {
577
805
  out(` · ${f.target}: ${f.count}${diff}${res.dryRun ? ' (not written)' : ''}`);
578
806
  }
579
807
  }
808
+ // Name the file AND the key, or the warning is not actionable — the
809
+ // whole point is that you can go look at what was kept.
810
+ const conflicted = res.locales.flatMap((l) => l.files.flatMap((f) => (f.conflicts ?? []).map((key) => ({ target: f.target, key }))));
811
+ if (conflicted.length > 0) {
812
+ out(`⚠ ${conflicted.length} local edit(s) are newer than the server and were NOT overwritten:`);
813
+ const width = Math.max(...conflicted.map((c) => c.target.length));
814
+ for (const c of conflicted)
815
+ out(` ${c.target.padEnd(width)} ${c.key}`);
816
+ out(' keep them and `lionrapid push --target <locale>`, or `lionrapid pull --force`');
817
+ out(' to take the server\'s version instead.');
818
+ }
580
819
  const failed = res.locales.flatMap((l) => l.files).filter((f) => f.error);
581
820
  if (failed.length > 0) {
582
821
  out(`⚠ ${failed.length} file(s) failed — the successful files were still written`);
@@ -610,7 +849,17 @@ export function buildProgram() {
610
849
  allowLocked: options.allowLocked,
611
850
  });
612
851
  const out = options.json ? console.error : console.log;
613
- if (res.issues.length === 0) {
852
+ const notice = noTargetsNotice(ctx.config);
853
+ if (notice)
854
+ out(notice);
855
+ if (ctx.config.targetLocales.length === 0) {
856
+ // Zero targets means zero comparisons were made. Saying "all
857
+ // translated" here is a vacuous truth that reads as a clean bill of
858
+ // health — the one thing validate exists not to do.
859
+ out(`· nothing to validate: ${res.sourceKeys} source key(s), but no targetLocales are configured.\n` +
860
+ ' Add target languages in the dashboard, then re-run `lionrapid init`.');
861
+ }
862
+ else if (res.issues.length === 0) {
614
863
  out(`✓ all ${res.sourceKeys} source key(s) translated across target locales`);
615
864
  }
616
865
  else {
@@ -23,6 +23,8 @@ export interface DoctorOptions {
23
23
  config?: string;
24
24
  /** Injectable fetch for tests; defaults to the client's global fetch. */
25
25
  fetchImpl?: FetchImpl;
26
+ /** Credentials file path (defaults to ~/.lionrapid/credentials). */
27
+ credentialsPath?: string;
26
28
  }
27
29
  export declare function runDoctor(opts: DoctorOptions): Promise<DoctorResult>;
28
30
  //# sourceMappingURL=doctor.d.ts.map
@@ -10,11 +10,33 @@
10
10
  */
11
11
  import { existsSync } from 'node:fs';
12
12
  import { resolve } from 'node:path';
13
- import { expandGlobEntries, findConfigPath, loadConfig, resolveApiKey, } from '../config.js';
13
+ import { expandGlobEntries, findConfigPath, loadConfig, resolveApiKey, keyCapability, listCredentialServers, ConfigError, } from '../config.js';
14
14
  import { LionRapidApiClient } from '../api-client.js';
15
+ /**
16
+ * First line only. Right for a network error (whose `cause` chain is long and
17
+ * repetitive), wrong for a ConfigError — see {@link configDetail}.
18
+ */
15
19
  function msg(err) {
16
20
  return err instanceof Error ? err.message.split('\n')[0] : String(err);
17
21
  }
22
+ /**
23
+ * A config failure, with its issues.
24
+ *
25
+ * `msg()` kept the first line only, so `Invalid lionrapid config:` printed
26
+ * with every bullet the validator had produced discarded — a header promising
27
+ * a list, followed by nothing. Other commands print `err.message` whole and
28
+ * showed the bullets, which is what made the renderer look inconsistent: it is
29
+ * not per-field, it is per-command. ConfigError carries the issues as typed
30
+ * data, so use that rather than re-splitting a string.
31
+ */
32
+ function configDetail(err) {
33
+ if (err instanceof ConfigError && err.issues.length > 0) {
34
+ return `${err.message.split('\n')[0]} ${err.issues
35
+ .map((i) => `${i.path}: ${i.message}`)
36
+ .join('; ')}`;
37
+ }
38
+ return msg(err);
39
+ }
18
40
  export async function runDoctor(opts) {
19
41
  const checks = [];
20
42
  // 1. Config present + valid.
@@ -39,16 +61,36 @@ export async function runDoctor(opts) {
39
61
  }
40
62
  }
41
63
  catch (err) {
42
- checks.push({ name: 'config', ok: false, detail: msg(err) });
64
+ checks.push({ name: 'config', ok: false, detail: configDetail(err) });
43
65
  }
44
66
  // 2. API key resolvable (never print the key itself).
67
+ //
68
+ // Keys are stored PER SERVER URL, so without a valid config there is no
69
+ // server to look one up by — the lookup cannot run, it does not fail. Saying
70
+ // "No API key found" here asserted something false about a key that was
71
+ // present and correct, and sent the owner hunting through a credentials file
72
+ // that had nothing wrong with it. Report the truth: skipped, and what IS
73
+ // saved, so they can see for themselves.
45
74
  let apiKey;
75
+ const savedScopes = listCredentialServers(opts.credentialsPath);
46
76
  try {
47
- apiKey = resolveApiKey({ server: config?.server });
48
- checks.push({ name: 'apiKey', ok: true, detail: 'resolved from env/credentials' });
77
+ apiKey = resolveApiKey({ server: config?.server, credentialsPath: opts.credentialsPath });
78
+ // Name what the key can DO, not just that it exists. A pk_ holder
79
+ // otherwise discovers the targets:write line mid-publish, as a bare 403.
80
+ checks.push({
81
+ name: 'apiKey',
82
+ ok: true,
83
+ detail: `resolved from env/credentials · ${keyCapability(apiKey).summary}`,
84
+ });
49
85
  }
50
86
  catch (err) {
51
- checks.push({ name: 'apiKey', ok: false, detail: msg(err) });
87
+ checks.push({
88
+ name: 'apiKey',
89
+ ok: false,
90
+ detail: config
91
+ ? msg(err)
92
+ : `skipped — needs a valid config (keys are saved per server URL)${savedScopes.length > 0 ? `; saved: ${savedScopes.join(', ')}` : ''}`,
93
+ });
52
94
  }
53
95
  // 3. Server reachable + token accepted (no retries — doctor wants fast feedback).
54
96
  if (config && apiKey) {