@lionrapid/cli 0.2.2 → 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/dist/cli.js CHANGED
@@ -8,17 +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
+ import { defaultJobHistoryPath, recordJobs, } from './job-history.js';
18
19
  import { loadContext, loadOfflineContext } from './commands/context.js';
19
- import { runInit, fetchLocaleDefaults, detectLocalesOnDisk } from './commands/init.js';
20
- 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';
21
22
  import { runPush } from './commands/push.js';
23
+ import { runWhoami } from './commands/whoami.js';
24
+ import { runStatus, explainStatus } from './commands/status.js';
25
+ import { formatLocal } from './time.js';
22
26
  import { runPull } from './commands/pull.js';
23
27
  import { runValidate } from './commands/validate.js';
24
28
  import { runTypesGenerate, runTypesGenerateServer, runTypesCheck } from './commands/types.js';
@@ -58,14 +62,197 @@ function fail(err, ctx = {}) {
58
62
  else {
59
63
  console.error(`✖ ${err instanceof Error ? err.message : String(err)}`);
60
64
  }
65
+ // Anything still true despite the failure — e.g. batches that were accepted
66
+ // before it. Printed after the error so the cause reads first.
67
+ for (const note of ctx.notes ?? [])
68
+ console.error(note);
61
69
  if (ctx.json) {
62
70
  process.stdout.write(`${JSON.stringify(errorEnvelope(ctx.command ?? 'lionrapid', err), null, 2)}\n`);
63
71
  }
64
72
  process.exit(exitCodeForError(err));
65
73
  }
74
+ /**
75
+ * Save this push's receipts to `~/.lionrapid/jobs.json`. Called on the success
76
+ * path AND from push's catch: a batch that was accepted before a later failure
77
+ * is still queued on the server, and its id is the only way to find out how it
78
+ * ended.
79
+ */
80
+ function saveQueuedJobs(jobs, ctx) {
81
+ const pushedAt = new Date().toISOString();
82
+ const entries = jobs.map((job) => ({
83
+ id: job.id,
84
+ server: ctx.config.server,
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 } : {}),
88
+ namespace: job.namespace,
89
+ locale: ctx.config.sourceLocale,
90
+ files: [job.file],
91
+ keys: job.keys,
92
+ pushedAt,
93
+ status: job.status,
94
+ }));
95
+ recordJobs(entries);
96
+ return entries;
97
+ }
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
+ }
114
+ }
115
+ /** "128 key(s) from locales/en.json → app" — what a job is carrying. */
116
+ function jobPayload(view) {
117
+ const h = view.history;
118
+ if (!h)
119
+ return 'pushed from another machine or checkout';
120
+ return `${h.keys} key(s) from ${h.files.join(', ')} → ${h.namespace}`;
121
+ }
122
+ /** The detail view for `lionrapid status <job-id>`. */
123
+ function printJobDetail(view, out) {
124
+ const { icon, label, detail } = explainStatus(view);
125
+ out(`${icon} job ${view.id} — ${label}`);
126
+ out(` what ${jobPayload(view)}`);
127
+ if (view.history)
128
+ out(` pushed ${when(view.history.pushedAt)}`);
129
+ out(` verdict ${detail}`);
130
+ const job = view.job;
131
+ if (job?.deadLetterReason)
132
+ out(` reason ${job.deadLetterReason}`);
133
+ else if (view.history?.deadLetterReason)
134
+ out(` reason ${view.history.deadLetterReason}`);
135
+ const error = job?.error ?? view.history?.error;
136
+ if (error)
137
+ out(` error ${error}`);
138
+ if (job && job.retryCount > 0) {
139
+ out(` attempts ${job.retryCount} of ${job.maxRetries}`);
140
+ for (const e of job.retryErrors)
141
+ out(` · ${e}`);
142
+ }
143
+ if (job?.nextRetryAt)
144
+ out(` next try ${when(job.nextRetryAt)}`);
145
+ if (job?.completedAt)
146
+ out(` finished ${when(job.completedAt)}`);
147
+ // Never let a remembered status read as a live one.
148
+ if (view.source === 'local') {
149
+ out(` ⚠ last known on this machine${view.history?.checkedAt ? ` (checked ${when(view.history.checkedAt)})` : ''} — not confirmed with the server just now`);
150
+ }
151
+ else if (view.source === 'local-final') {
152
+ out(' · answered from this machine\'s record — the state is final, so no server call was needed');
153
+ }
154
+ if (label === 'dead-lettered') {
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.');
163
+ }
164
+ else if (label === 'queued' || label === 'processing' || label === 'retrying') {
165
+ out(` next: \`lionrapid status ${view.id}\` again in a moment.`);
166
+ }
167
+ }
168
+ /** The listing for bare `lionrapid status` / `lionrapid status list`. */
169
+ function printJobList(res, out) {
170
+ out(`· ${res.jobs.length} job(s) pushed from this machine, newest first:`);
171
+ for (const view of res.jobs) {
172
+ const { icon, label } = explainStatus(view);
173
+ const h = view.history;
174
+ const where = h ? `${h.files.join(', ')} → ${h.namespace} · ${h.keys} key(s)` : '';
175
+ const stale = view.source === 'local' ? ' (last known)' : '';
176
+ out(` ${icon} ${view.id} ${label.padEnd(14)}${where} · ${when(h?.pushedAt)}${stale}`);
177
+ }
178
+ out(' next: `lionrapid status <job-id>` for the full story on one of them.');
179
+ }
180
+ /** Human rendering of a status result. Machine callers use `--json`. */
181
+ function printStatus(res, out) {
182
+ if (res.notFound) {
183
+ out('✖ no such job — the server does not know that id, and this machine has no record of it.');
184
+ out(' Finished jobs are kept about an hour server-side; older ones survive only in');
185
+ out(` ${res.historyPath}.`);
186
+ if (res.offline)
187
+ out(` (the server could not be asked: ${res.offline})`);
188
+ return;
189
+ }
190
+ if (res.mode === 'job') {
191
+ const view = res.jobs[0];
192
+ if (view)
193
+ printJobDetail(view, out);
194
+ return;
195
+ }
196
+ if (res.jobs.length > 0) {
197
+ printJobList(res, out);
198
+ if (res.offline) {
199
+ out(` ⚠ the server could not be reached (${res.offline}) — every line above is last known.`);
200
+ }
201
+ return;
202
+ }
203
+ // Nothing local: say so plainly, then show whatever the server will tell us.
204
+ out(`· no pushes recorded on this machine (${res.historyPath})`);
205
+ if (res.queue) {
206
+ const counts = Object.entries(res.queue.statusCounts)
207
+ .map(([k, v]) => `${v} ${k}`)
208
+ .join(', ');
209
+ out(` server queue: depth ${res.queue.queueDepth}${counts ? ` · ${counts}` : ''} · worker ${res.queue.workerRunning ? 'running' : 'NOT running'}`);
210
+ }
211
+ if (res.deadLettered && res.deadLettered.length > 0) {
212
+ out(` ✖ dead-letter queue: ${res.deadLettered.length} job(s) the backend gave up on:`);
213
+ for (const job of res.deadLettered) {
214
+ out(` ${job.jobId} ${job.deadLetterReason ?? job.error ?? 'no reason given'}`);
215
+ }
216
+ }
217
+ else if (res.deadLettered) {
218
+ out(' ✓ dead-letter queue is empty — nothing was dropped.');
219
+ }
220
+ if (res.queueUnavailable)
221
+ out(` · queue view unavailable: ${res.queueUnavailable}`);
222
+ if (res.offline)
223
+ out(` · the server could not be reached: ${res.offline}`);
224
+ out(' next: run `lionrapid push`, then `lionrapid status`.');
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
+ }
66
250
  /**
67
251
  * One-line interactive prompt on stderr with a default. Non-TTY (CI, pipes)
68
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`.
69
256
  */
70
257
  function askText(question, def) {
71
258
  if (!process.stdin.isTTY)
@@ -98,7 +285,6 @@ export function buildProgram() {
98
285
  program
99
286
  .command('init')
100
287
  .description('Scaffold a lionrapid.yml config in the current project')
101
- .option('--project <id>', 'project id')
102
288
  .option('--server <url>', 'API server URL')
103
289
  .option('--source <locale>', 'source locale')
104
290
  .option('--targets <list>', 'comma-separated target locales')
@@ -122,10 +308,20 @@ export function buildProgram() {
122
308
  const known = existingServer ?? (loggedIn.length === 1 ? loggedIn[0] : undefined);
123
309
  // Only ask when there's genuinely nothing to go on. After a `login`,
124
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
+ }
125
317
  const server = options.server ?? known ?? (await askText('Server URL', DEFAULT_SERVER));
126
318
  // The project field is a local label (the API key selects the real
127
319
  // project) — the directory name is a better default than a question.
128
- 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');
129
325
  // Locale resolution ladder: explicit flags → the project's server
130
326
  // config → locales already present in the repo → static placeholders.
131
327
  // Every rung degrades quietly, so init still works fully offline.
@@ -133,20 +329,21 @@ export function buildProgram() {
133
329
  let targets = options.targets?.split(',').map((s) => s.trim());
134
330
  const notes = [];
135
331
  if (!source || !targets) {
136
- let key = options.key;
137
- if (!key) {
138
- try {
139
- key = resolveApiKey({ server });
140
- }
141
- catch {
142
- // no stored key for this server — prefill stays optional
143
- }
144
- }
145
- if (!key && process.stdin.isTTY) {
146
- key =
147
- (await promptHidden('API key to prefill locales (Enter to skip): ')).trim() ||
148
- undefined;
149
- }
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;
150
347
  let serverStatus = 'no-key';
151
348
  let serverTargets;
152
349
  if (key) {
@@ -167,6 +364,30 @@ export function buildProgram() {
167
364
  source ??= prefill.sourceLocale; // the source locale IS real
168
365
  }
169
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
+ }
170
391
  const onDisk = detectLocalesOnDisk(process.cwd(), source ?? 'en');
171
392
  if (serverStatus === 'ok' && serverTargets) {
172
393
  notes.push(`locales from ${server}: ${source} → ${serverTargets.join(', ')}`);
@@ -203,7 +424,7 @@ export function buildProgram() {
203
424
  }
204
425
  }
205
426
  const res = runInit(process.cwd(), {
206
- project,
427
+ ...(projectId ? { projectId } : {}),
207
428
  server,
208
429
  sourceLocale: source,
209
430
  targetLocales: targets,
@@ -223,6 +444,14 @@ export function buildProgram() {
223
444
  console.log(` detected: ${res.detected.stacks.join(', ')}`);
224
445
  for (const note of notes)
225
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
+ }
226
455
  if (res.conflicts.length > 0) {
227
456
  // Never silently overwrite a value the developer set. Show the
228
457
  // difference and let them resolve it in the file.
@@ -235,9 +464,23 @@ export function buildProgram() {
235
464
  console.log(' Edit lionrapid.yml to resolve, or re-run with --force to take the');
236
465
  console.log(' project values wholesale.');
237
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
+ }
238
481
  const ready = res.conflicts.length === 0 && (targets?.length ?? 0) > 0;
239
482
  console.log(ready
240
- ? ' next: `lionrapid doctor` to check the setup, then `lionrapid push`.'
483
+ ? ' next: `lionrapid push --dry-run`, then `lionrapid push`.'
241
484
  : ' next: fix the above in lionrapid.yml, then run `lionrapid doctor`.');
242
485
  }
243
486
  catch (err) {
@@ -269,18 +512,84 @@ export function buildProgram() {
269
512
  }
270
513
  });
271
514
  program
272
- .command('logout')
273
- .description('Remove a saved API key from ~/.lionrapid/credentials')
274
- .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')
275
517
  .option('--json', 'emit the result as JSON on stdout (prose to stderr)')
518
+ .option('-c, --config <path>', 'path to lionrapid.yml')
276
519
  .action((options) => {
277
520
  try {
278
- const res = runLogout({ server: options.server });
521
+ const res = runWhoami({ cwd: process.cwd(), config: options.config });
279
522
  const out = options.json ? console.error : console.log;
280
- const what = res.scope === 'server' ? `key for ${options.server}` : 'default key';
281
- 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
+ }
282
554
  if (options.json)
283
- 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
+ }
284
593
  }
285
594
  catch (err) {
286
595
  fail(err, { json: options.json, command: 'logout' });
@@ -292,26 +601,64 @@ export function buildProgram() {
292
601
  .option('--target <locale>', 'push an already-translated target locale instead of the source')
293
602
  .option('--publish', 'in --target mode, mark pushed rows ready (public)')
294
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)')
295
606
  .option('--json', 'emit the result as JSON on stdout (prose to stderr)')
296
607
  .option('-c, --config <path>', 'path to lionrapid.yml')
297
608
  .action(async (options) => {
609
+ // Receipts are collected as they arrive, not from the result, so a
610
+ // total failure (which rethrows) still saves the ids of the batches
611
+ // that DID reach the server.
612
+ const queued = [];
613
+ let ctx;
298
614
  try {
299
- const ctx = loadContext({ config: options.config });
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
+ }
300
627
  const res = await runPush(ctx.config, ctx.client, {
301
628
  cwd: ctx.cwd,
302
629
  target: options.target,
303
630
  publish: options.publish,
304
631
  dryRun: options.dryRun,
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,
305
637
  });
306
638
  const out = options.json ? console.error : console.log;
639
+ const notice = noTargetsNotice(ctx.config);
640
+ if (notice)
641
+ out(notice);
307
642
  const tag = res.dryRun ? '[dry-run] would push' : '✓ pushed';
308
- 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
+ }
309
655
  for (const f of res.files) {
310
656
  if (f.error) {
311
657
  out(` · ${f.file}: ✖ ${f.error}`);
312
658
  continue;
313
659
  }
314
- 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}`);
315
662
  }
316
663
  const failed = res.files.filter((f) => f.error);
317
664
  if (failed.length > 0) {
@@ -322,13 +669,77 @@ export function buildProgram() {
322
669
  out('⚠ uploaded as drafts but not published — publishing needs an editor session token.\n' +
323
670
  ' Set LIONRAPID_EDITOR_TOKEN, or publish in the dashboard.');
324
671
  }
672
+ // The whole point: "pushed" above is what we SENT. The server stores
673
+ // it in the background, so print what to watch and where it is saved.
674
+ if (res.jobs.length > 0) {
675
+ saveQueuedJobs(res.jobs, ctx);
676
+ out(`⧗ queued as ${res.jobs.length} background job(s) — the server stores these rows\n` +
677
+ ' asynchronously, so "pushed" means sent, not yet stored:');
678
+ for (const job of res.jobs) {
679
+ out(` ${job.id} ${job.file} (${job.keys} key(s))`);
680
+ }
681
+ out(` next: lionrapid status ${res.jobs[0].id}`);
682
+ out(` (saved to ${defaultJobHistoryPath()} — bare \`lionrapid status\` lists them)`);
683
+ }
684
+ else if (res.mode === 'source' && !res.dryRun && res.pushed > 0) {
685
+ // Accepted, but no ids came back — an older server or a proxy that
686
+ // ate the body. Say it rather than implying everything is confirmed.
687
+ out('⚠ the server accepted the batches but returned no job ids, so there is nothing\n' +
688
+ ' to poll — `lionrapid validate` is the way to confirm these rows landed.');
689
+ }
325
690
  if (options.json)
326
691
  emitJson('push', failed.length === 0, res);
327
692
  if (failed.length > 0)
328
693
  process.exit(ExitCode.PARTIAL);
329
694
  }
330
695
  catch (err) {
331
- fail(err, { json: options.json, command: 'push' });
696
+ // Batches accepted before the failure are still queued on the server.
697
+ // Save their ids and say so — the whole point is never being blind.
698
+ const notes = [];
699
+ if (ctx && queued.length > 0) {
700
+ saveQueuedJobs(queued, ctx);
701
+ 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.');
702
+ }
703
+ fail(err, { json: options.json, command: 'push', notes });
704
+ }
705
+ });
706
+ program
707
+ .command('status')
708
+ .argument('[job-id]', 'a job id from `push` (omit, or pass "list", for every recorded job)')
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')
711
+ .option('--json', 'emit the result as JSON on stdout (prose to stderr)')
712
+ .option('-c, --config <path>', 'path to lionrapid.yml')
713
+ .action(async (jobId, options) => {
714
+ try {
715
+ // `status` must answer from anywhere: run outside a project, or with no
716
+ // saved key, it falls back to the on-disk history instead of failing.
717
+ let ctx;
718
+ try {
719
+ ctx = loadContext({ config: options.config });
720
+ }
721
+ catch {
722
+ ctx = undefined;
723
+ }
724
+ const res = await runStatus({
725
+ jobId,
726
+ client: ctx?.client,
727
+ server: ctx?.config.server,
728
+ refresh: options.refresh,
729
+ });
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
+ }
736
+ if (options.json)
737
+ emitJson('status', !res.notFound, res);
738
+ if (res.notFound)
739
+ process.exit(ExitCode.VALIDATION);
740
+ }
741
+ catch (err) {
742
+ fail(err, { json: options.json, command: 'status' });
332
743
  }
333
744
  });
334
745
  program
@@ -356,6 +767,9 @@ export function buildProgram() {
356
767
  },
357
768
  });
358
769
  const out = options.json ? console.error : console.log;
770
+ const notice = noTargetsNotice(ctx.config);
771
+ if (notice)
772
+ out(notice);
359
773
  const mark = res.dryRun ? '[dry-run]' : '✓';
360
774
  for (const loc of res.locales) {
361
775
  const pending = loc.pending > 0 ? ` (${loc.pending} pending)` : '';
@@ -371,6 +785,17 @@ export function buildProgram() {
371
785
  out(` · ${f.target}: ${f.count}${diff}${res.dryRun ? ' (not written)' : ''}`);
372
786
  }
373
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
+ }
374
799
  const failed = res.locales.flatMap((l) => l.files).filter((f) => f.error);
375
800
  if (failed.length > 0) {
376
801
  out(`⚠ ${failed.length} file(s) failed — the successful files were still written`);
@@ -404,7 +829,17 @@ export function buildProgram() {
404
829
  allowLocked: options.allowLocked,
405
830
  });
406
831
  const out = options.json ? console.error : console.log;
407
- 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) {
408
843
  out(`✓ all ${res.sourceKeys} source key(s) translated across target locales`);
409
844
  }
410
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