@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/CHANGELOG.md +128 -2
- package/dist/api-client.d.ts +79 -1
- package/dist/api-client.js +60 -1
- package/dist/cli.js +294 -45
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +47 -5
- package/dist/commands/init.d.ts +73 -2
- package/dist/commands/init.js +0 -0
- package/dist/commands/login.d.ts +50 -4
- package/dist/commands/login.js +70 -16
- package/dist/commands/pull.d.ts +15 -1
- package/dist/commands/pull.js +92 -4
- package/dist/commands/push.d.ts +62 -1
- package/dist/commands/push.js +198 -5
- package/dist/commands/status.d.ts +15 -0
- package/dist/commands/status.js +16 -5
- package/dist/commands/whoami.d.ts +50 -0
- package/dist/commands/whoami.js +66 -0
- package/dist/config.d.ts +100 -5
- package/dist/config.js +239 -22
- package/dist/history.d.ts +44 -0
- package/dist/history.js +65 -0
- package/dist/job-history.d.ts +11 -2
- package/dist/job-history.js +1 -13
- package/dist/lockfile.d.ts +90 -0
- package/dist/lockfile.js +110 -0
- package/dist/time.d.ts +27 -0
- package/dist/time.js +53 -0
- package/package.json +2 -2
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 {
|
|
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,
|
|
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
|
-
|
|
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
|
-
/**
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
|
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('
|
|
416
|
-
.description('
|
|
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 =
|
|
521
|
+
const res = runWhoami({ cwd: process.cwd(), config: options.config });
|
|
422
522
|
const out = options.json ? console.error : console.log;
|
|
423
|
-
|
|
424
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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({
|
|
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) {
|