@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/CHANGELOG.md +123 -0
- package/README.md +15 -1
- package/dist/api-client.d.ts +129 -1
- package/dist/api-client.js +139 -2
- package/dist/cli.js +469 -34
- 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 +73 -0
- package/dist/commands/push.js +175 -3
- package/dist/commands/status.d.ts +91 -0
- package/dist/commands/status.js +232 -0
- 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 +106 -0
- package/dist/job-history.js +163 -0
- 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 +1 -1
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 {
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
|
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('
|
|
273
|
-
.description('
|
|
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 =
|
|
521
|
+
const res = runWhoami({ cwd: process.cwd(), config: options.config });
|
|
279
522
|
const out = options.json ? console.error : console.log;
|
|
280
|
-
|
|
281
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|