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