@lionrapid/cli 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -3,7 +3,82 @@
3
3
  All notable changes to `@lionrapid/cli`. Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/); this package follows semver.
5
5
 
6
- ## Unreleased
6
+ ## 0.4.0
7
+
8
+ ### Changed — behaviour, read this before upgrading
9
+
10
+ - **`push` is now incremental.** It diffs your sources against a new
11
+ `.lionrapid/lock.json` and sends only what changed; a second run of an
12
+ unchanged project pushes nothing. **Commit the lockfile** — like
13
+ `package-lock.json` it is the shared baseline, and a per-machine one would
14
+ make CI and a laptop disagree about what changed.
15
+
16
+ Recovery matters here: a batch the server accepts with `202` is recorded as
17
+ sent, and nothing waits for the job. If that job later dead-letters, a plain
18
+ re-push **skips** those rows. Use `push --backfill-missing` (sends exactly
19
+ what the server is missing) or `push --force` (resends everything).
20
+
21
+ - **`LIONRAPID_API_KEY` is now held to the same trusted-host rule as a saved
22
+ default key.** A `server:` that is not `*.lionrapid.com` or localhost will
23
+ refuse to receive it, because CI is precisely where a real secret meets a
24
+ `lionrapid.yml` that arrives by pull request. Self-hosted or staging: set
25
+ `LIONRAPID_ALLOW_UNTRUSTED_HOST=1` (accepted only as `1`/`true`). This
26
+ knowingly reverses the earlier position that CI sets the variable
27
+ deliberately — in CI the operator and the `server:` value are set by
28
+ different people, so exporting a key is not the same as approving its
29
+ destination.
30
+
31
+ - **`DEFAULT_SERVER` is `https://app.lionrapid.com`.** It was
32
+ `api.lionrapid.com`, which has no DNS record.
33
+
34
+ - **`targetLocales` is optional.** A config carrying only a source stanza is
35
+ valid; `push` uploads sources and warns that `pull` has nothing to do.
36
+ Previously `init` wrote a config its own schema rejected.
37
+
38
+ - **`project:` (free text) is removed.** The API key selects the project and
39
+ nothing in the config does; the field was a label nobody checked. A config
40
+ still carrying one parses and ignores it.
41
+
42
+ ### Added
43
+
44
+ - **`lionrapid init` is one pass** — detect, resolve the server, prompt/verify
45
+ and **save** the key, fetch locales, capture `projectId`, write the config.
46
+ No second `login`, no second `init`, no hand-editing.
47
+ - **`lionrapid whoami`** — which key is about to be used and where it is going.
48
+ Offline, masked to the last four characters, names an env override explicitly.
49
+ - **A project-mismatch guard.** `push` refuses _before sending a single row_
50
+ when the config's `projectId` and the key's project disagree, naming both —
51
+ the fix depends on which one is wrong.
52
+ - **`pull` no longer clobbers local edits.** An edit newer than the server is
53
+ named and kept; `--force` takes the server's version and snapshots yours to
54
+ `.lionrapid/history/` first (gitignored — a local safety copy, not a team
55
+ fact). `push --force` does the same in the other direction.
56
+ - **`push --dry-run`** reports the diff: new, changed, unchanged.
57
+ - **`logout`** removes the only scope when there is one, offers a picker when
58
+ there are several, refuses non-interactively, and `--all` clears everything.
59
+ - **Key-capability guidance** in `doctor`, `whoami` and `push` — a `pk_` cannot
60
+ write or publish targets, said before you hit the 403.
61
+ - **`status --refresh`**, which never downgrades a terminal verdict: a
62
+ `completed` job the server has since purged still reads `completed`.
63
+
64
+ ### Fixed
65
+
66
+ - `doctor` blamed a missing API key when the config was invalid; it now says
67
+ **skipped** and prints the config's actual problems.
68
+ - The no-key error named neither the server it wanted nor the scopes it had.
69
+ - `status` showed jobs finishing **before** they were pushed — offset-less
70
+ server timestamps were parsed as local time against a UTC clock.
71
+ - The credentials path ignored `process.env.HOME`, so the test suite read the
72
+ real `~/.lionrapid/credentials`.
73
+ - `validate` reported "all source keys translated" against **zero** target
74
+ locales.
75
+ - Four NUL bytes in `init.ts` made the file invisible to `grep`, for everyone,
76
+ permanently, because they were committed. A repo-wide guard test now fails on
77
+ any recurrence. Note `file(1)` does **not** detect this.
78
+ - `types check` is proven to run offline; a lint that needs credentials is a
79
+ lint nobody runs in CI.
80
+
81
+ ## 0.3.0 — 2026-08-06
7
82
 
8
83
  ### Added
9
84
 
@@ -13,7 +88,6 @@ All notable changes to `@lionrapid/cli`. Format based on
13
88
  `void`). A push whose backend job later failed or dead-lettered was therefore
14
89
  indistinguishable from one that worked: `pushed N string(s)` described the
15
90
  CLI's own outbox and nothing else. Now:
16
-
17
91
  - `syncBatch` returns the server's `BatchSyncJobResponse`, and `push` collects
18
92
  every `job_ids` entry (not just the legacy `job_id`, so multi-locale batches
19
93
  stay observable);
@@ -105,6 +105,16 @@ export interface NamespaceBundle {
105
105
  notModified: boolean;
106
106
  /** True on a 423 — the namespace exists but nothing is approved for delivery yet. */
107
107
  locked: boolean;
108
+ /**
109
+ * The project this key resolves to, from `meta.project_uuid`.
110
+ *
111
+ * REAL BUT UNDECLARED: the route sets `response_model=TranslationResponse`
112
+ * yet returns a raw Response so it can attach the ETag header, which bypasses
113
+ * FastAPI model filtering — so the field ships without being in the contract
114
+ * (server task #70). Anyone tidying that route to return the declared model
115
+ * removes it, in a change that reads as cosmetic. Treat as optional forever.
116
+ */
117
+ projectId?: string;
108
118
  }
109
119
  export type FetchImpl = (input: string, init?: RequestInit) => Promise<Response>;
110
120
  export interface ApiClientOptions {
@@ -386,6 +386,9 @@ export class LionRapidApiClient {
386
386
  return {
387
387
  translations: normalizeTranslations(data.translations),
388
388
  version: data.meta?.version != null ? String(data.meta.version) : undefined,
389
+ ...(typeof data.meta?.project_uuid === 'string' && data.meta.project_uuid
390
+ ? { projectId: data.meta.project_uuid }
391
+ : {}),
389
392
  notModified: false,
390
393
  locked: false,
391
394
  };
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', 'send everything, overwriting server rows (saves them to .lionrapid/history/ first)')
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,52 @@ 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
+ out(` ${f.skipped ? '· skipped (missing)' : '·'} ${f.file}: ${f.count}${same}`);
464
662
  }
465
663
  const failed = res.files.filter((f) => f.error);
466
664
  if (failed.length > 0) {
@@ -509,6 +707,7 @@ export function buildProgram() {
509
707
  .command('status')
510
708
  .argument('[job-id]', 'a job id from `push` (omit, or pass "list", for every recorded job)')
511
709
  .description('Show what the server actually did with the strings `push` queued')
710
+ .option('--refresh', 'ask the server again even for a job already recorded as finished')
512
711
  .option('--json', 'emit the result as JSON on stdout (prose to stderr)')
513
712
  .option('-c, --config <path>', 'path to lionrapid.yml')
514
713
  .action(async (jobId, options) => {
@@ -526,8 +725,14 @@ export function buildProgram() {
526
725
  jobId,
527
726
  client: ctx?.client,
528
727
  server: ctx?.config.server,
728
+ refresh: options.refresh,
529
729
  });
530
- printStatus(res, options.json ? console.error : console.log);
730
+ const out = options.json ? console.error : console.log;
731
+ printStatus(res, out);
732
+ if (res.retention === 'purged') {
733
+ out(' · the server no longer retains this job (finished jobs are kept ~1h) —');
734
+ out(' the recorded verdict above stands.');
735
+ }
531
736
  if (options.json)
532
737
  emitJson('status', !res.notFound, res);
533
738
  if (res.notFound)
@@ -562,6 +767,9 @@ export function buildProgram() {
562
767
  },
563
768
  });
564
769
  const out = options.json ? console.error : console.log;
770
+ const notice = noTargetsNotice(ctx.config);
771
+ if (notice)
772
+ out(notice);
565
773
  const mark = res.dryRun ? '[dry-run]' : '✓';
566
774
  for (const loc of res.locales) {
567
775
  const pending = loc.pending > 0 ? ` (${loc.pending} pending)` : '';
@@ -577,6 +785,17 @@ export function buildProgram() {
577
785
  out(` · ${f.target}: ${f.count}${diff}${res.dryRun ? ' (not written)' : ''}`);
578
786
  }
579
787
  }
788
+ // Name the file AND the key, or the warning is not actionable — the
789
+ // whole point is that you can go look at what was kept.
790
+ const conflicted = res.locales.flatMap((l) => l.files.flatMap((f) => (f.conflicts ?? []).map((key) => ({ target: f.target, key }))));
791
+ if (conflicted.length > 0) {
792
+ out(`⚠ ${conflicted.length} local edit(s) are newer than the server and were NOT overwritten:`);
793
+ const width = Math.max(...conflicted.map((c) => c.target.length));
794
+ for (const c of conflicted)
795
+ out(` ${c.target.padEnd(width)} ${c.key}`);
796
+ out(' keep them and `lionrapid push --target <locale>`, or `lionrapid pull --force`');
797
+ out(' to take the server\'s version instead.');
798
+ }
580
799
  const failed = res.locales.flatMap((l) => l.files).filter((f) => f.error);
581
800
  if (failed.length > 0) {
582
801
  out(`⚠ ${failed.length} file(s) failed — the successful files were still written`);
@@ -610,7 +829,17 @@ export function buildProgram() {
610
829
  allowLocked: options.allowLocked,
611
830
  });
612
831
  const out = options.json ? console.error : console.log;
613
- if (res.issues.length === 0) {
832
+ const notice = noTargetsNotice(ctx.config);
833
+ if (notice)
834
+ out(notice);
835
+ if (ctx.config.targetLocales.length === 0) {
836
+ // Zero targets means zero comparisons were made. Saying "all
837
+ // translated" here is a vacuous truth that reads as a clean bill of
838
+ // health — the one thing validate exists not to do.
839
+ out(`· nothing to validate: ${res.sourceKeys} source key(s), but no targetLocales are configured.\n` +
840
+ ' Add target languages in the dashboard, then re-run `lionrapid init`.');
841
+ }
842
+ else if (res.issues.length === 0) {
614
843
  out(`✓ all ${res.sourceKeys} source key(s) translated across target locales`);
615
844
  }
616
845
  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