@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/dist/commands/push.js
CHANGED
|
@@ -17,10 +17,53 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
17
17
|
import { resolve } from 'node:path';
|
|
18
18
|
import { ConfigError } from '../config.js';
|
|
19
19
|
import { getAdapter } from '../adapters/index.js';
|
|
20
|
+
import { saveHistory } from '../history.js';
|
|
21
|
+
import { hashSource, lockKey, readLock, writeLock } from '../lockfile.js';
|
|
20
22
|
import { chunk } from './context.js';
|
|
21
23
|
// The server's enforced ceiling is 1000 items per sync/targets batch (Pydantic
|
|
22
24
|
// `max_length` on the request model) — 500 stays comfortably under it.
|
|
23
25
|
const BATCH = 500;
|
|
26
|
+
/**
|
|
27
|
+
* The source rows the server actually holds for a namespace.
|
|
28
|
+
*
|
|
29
|
+
* A locked or missing namespace answers "nothing", which is the right reading
|
|
30
|
+
* for both callers: `--backfill-missing` then sends everything, and
|
|
31
|
+
* `--force` has nothing to snapshot.
|
|
32
|
+
*/
|
|
33
|
+
async function sourceRowsOnServer(client, sourceLocale, namespace) {
|
|
34
|
+
try {
|
|
35
|
+
const bundle = await client.getNamespace(sourceLocale, namespace);
|
|
36
|
+
return new Map(Object.entries(bundle.translations));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return new Map();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The project this API KEY resolves to, or undefined when the server does not
|
|
44
|
+
* say.
|
|
45
|
+
*
|
|
46
|
+
* Read from `meta.project_uuid` on a translations fetch. That field is real
|
|
47
|
+
* but UNDECLARED — the route returns a raw Response to set its ETag header,
|
|
48
|
+
* bypassing the declared response model (server task #70) — so "absent" must
|
|
49
|
+
* stay a normal, non-blocking answer forever, not an error. A locked (423) or
|
|
50
|
+
* missing namespace also answers nothing, which is right: a project with no
|
|
51
|
+
* rows yet cannot identify itself this way.
|
|
52
|
+
*/
|
|
53
|
+
async function resolveKeyProject(client, config) {
|
|
54
|
+
const namespaces = new Set(config.files.map((f) => f.namespace ?? config.namespace));
|
|
55
|
+
for (const namespace of namespaces) {
|
|
56
|
+
try {
|
|
57
|
+
const bundle = await client.getNamespace(config.sourceLocale, namespace);
|
|
58
|
+
if (bundle.projectId)
|
|
59
|
+
return bundle.projectId;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Try the next namespace; an unreachable server is the caller's problem.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
24
67
|
export async function runPush(config, client, opts) {
|
|
25
68
|
const mode = opts.target ? 'target' : 'source';
|
|
26
69
|
// The backend does NOT validate target locales — `targets/batch` silently
|
|
@@ -40,6 +83,43 @@ export async function runPush(config, client, opts) {
|
|
|
40
83
|
const canPublish = mode === 'target' && opts.publish === true && client.canPublish;
|
|
41
84
|
const publishSkipped = mode === 'target' && opts.publish === true && !client.canPublish;
|
|
42
85
|
let pushed = 0;
|
|
86
|
+
let skipped = 0;
|
|
87
|
+
let diffAdded = 0;
|
|
88
|
+
let diffChanged = 0;
|
|
89
|
+
// INCREMENTAL. The lock says what the server already has, so a second run
|
|
90
|
+
// sends "4 changed" rather than all 653. No lock (first run, or a fresh
|
|
91
|
+
// checkout) means we know nothing, which is a backfill — send everything.
|
|
92
|
+
//
|
|
93
|
+
// Source mode only: `push --target` sends LOCAL values, and recording those
|
|
94
|
+
// as server state would be the fabrication `pull` is careful to avoid.
|
|
95
|
+
const lock = opts.lockPath && mode === 'source' ? readLock(opts.lockPath) : undefined;
|
|
96
|
+
// One asks "what is the server missing", the other says "ignore all of that
|
|
97
|
+
// and take mine". Silently picking one would be a coin toss with the user's
|
|
98
|
+
// data.
|
|
99
|
+
if (opts.force && opts.backfillMissing) {
|
|
100
|
+
throw new ConfigError('--force and --backfill-missing ask for opposite things: one overwrites every server\n' +
|
|
101
|
+
' row, the other touches only the rows that are missing. Pick one.');
|
|
102
|
+
}
|
|
103
|
+
// THE GUARD. Runs before a single row is sent, because it is worthless
|
|
104
|
+
// afterwards — on 2026-08-07, 653 strings reported as `tm-frontpage` landed
|
|
105
|
+
// in another project, and nothing said so until an hour of hunting later.
|
|
106
|
+
let projectCheck;
|
|
107
|
+
if (config.projectId) {
|
|
108
|
+
const actual = await resolveKeyProject(client, config);
|
|
109
|
+
if (actual === undefined) {
|
|
110
|
+
projectCheck = 'unavailable';
|
|
111
|
+
}
|
|
112
|
+
else if (actual !== config.projectId) {
|
|
113
|
+
throw new ConfigError('project mismatch — this push would go somewhere else.\n' +
|
|
114
|
+
` lionrapid.yml projectId: ${config.projectId}\n` +
|
|
115
|
+
` this API key resolves to: ${actual}\n` +
|
|
116
|
+
' The API key selects the project; the config cannot. Update projectId in\n' +
|
|
117
|
+
' lionrapid.yml, or switch keys.');
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
projectCheck = 'ok';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
43
123
|
let firstError;
|
|
44
124
|
for (const file of config.files) {
|
|
45
125
|
const adapter = getAdapter(file.format);
|
|
@@ -57,7 +137,54 @@ export async function runPush(config, client, opts) {
|
|
|
57
137
|
try {
|
|
58
138
|
const parseLocale = mode === 'source' ? config.sourceLocale : opts.target;
|
|
59
139
|
const entries = adapter.parse(readFileSync(absPath, 'utf8'), { locale: parseLocale });
|
|
60
|
-
const
|
|
140
|
+
const all = entries.map((e) => ({ key: e.key, value: e.value }));
|
|
141
|
+
const hashes = new Map(all.map((it) => [it.key, hashSource(it.value)]));
|
|
142
|
+
// Three modes. `--force` and `--backfill-missing` both ASK THE SERVER
|
|
143
|
+
// rather than trusting the lock, which is what makes them a recovery
|
|
144
|
+
// path: the lock can be wrong (a 202 whose job later died records rows
|
|
145
|
+
// as sent that never landed), and only the server knows the truth.
|
|
146
|
+
let items;
|
|
147
|
+
if (opts.force) {
|
|
148
|
+
const onServer = await sourceRowsOnServer(client, config.sourceLocale, namespace);
|
|
149
|
+
if (!opts.dryRun) {
|
|
150
|
+
saveHistory(opts.lockPath ?? opts.cwd, {
|
|
151
|
+
direction: 'push',
|
|
152
|
+
namespace,
|
|
153
|
+
locale: config.sourceLocale,
|
|
154
|
+
reason: `push --force overwrote these server rows from ${relPath}`,
|
|
155
|
+
entries: all
|
|
156
|
+
.filter((it) => onServer.has(it.key))
|
|
157
|
+
.map((it) => ({ key: it.key, value: onServer.get(it.key) })),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
items = all;
|
|
161
|
+
}
|
|
162
|
+
else if (opts.backfillMissing) {
|
|
163
|
+
const onServer = await sourceRowsOnServer(client, config.sourceLocale, namespace);
|
|
164
|
+
items = all.filter((it) => !onServer.has(it.key));
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
// Default: incremental against the lock — new keys and changed text.
|
|
168
|
+
items = lock
|
|
169
|
+
? all.filter((it) => lock.entries[lockKey(namespace, it.key)]?.sourceHash !== hashes.get(it.key))
|
|
170
|
+
: all;
|
|
171
|
+
}
|
|
172
|
+
skipped += all.length - items.length;
|
|
173
|
+
// A key the lock has never seen is NEW; one it has seen with a different
|
|
174
|
+
// hash is CHANGED. Counted from the lock even under --force, so the
|
|
175
|
+
// report describes the repo's relationship to the server rather than
|
|
176
|
+
// just restating the flag.
|
|
177
|
+
for (const it of items) {
|
|
178
|
+
const known = lock?.entries[lockKey(namespace, it.key)]?.sourceHash;
|
|
179
|
+
if (known === undefined || known === '')
|
|
180
|
+
diffAdded += 1;
|
|
181
|
+
else
|
|
182
|
+
diffChanged += 1;
|
|
183
|
+
}
|
|
184
|
+
if (items.length === 0) {
|
|
185
|
+
files.push({ file: relPath, count: 0, unchanged: all.length });
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
61
188
|
// Dry run: parse + count only, never touch the network.
|
|
62
189
|
if (!opts.dryRun) {
|
|
63
190
|
for (const batch of chunk(items, BATCH)) {
|
|
@@ -89,8 +216,21 @@ export async function runPush(config, client, opts) {
|
|
|
89
216
|
}
|
|
90
217
|
}
|
|
91
218
|
}
|
|
219
|
+
// Record ONLY what the server accepted, and only for a file that got
|
|
220
|
+
// through cleanly — a hash written for rows the server never took would
|
|
221
|
+
// make the next push skip them silently.
|
|
222
|
+
if (lock && !opts.dryRun) {
|
|
223
|
+
for (const it of items) {
|
|
224
|
+
const key = lockKey(namespace, it.key);
|
|
225
|
+
lock.entries[key] = { ...lock.entries[key], sourceHash: hashes.get(it.key) };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
92
228
|
pushed += items.length;
|
|
93
|
-
files.push({
|
|
229
|
+
files.push({
|
|
230
|
+
file: relPath,
|
|
231
|
+
count: items.length,
|
|
232
|
+
...(all.length > items.length ? { unchanged: all.length - items.length } : {}),
|
|
233
|
+
});
|
|
94
234
|
}
|
|
95
235
|
catch (err) {
|
|
96
236
|
firstError ??= err;
|
|
@@ -107,9 +247,19 @@ export async function runPush(config, client, opts) {
|
|
|
107
247
|
if (attempted.length > 0 && attempted.every((f) => f.error) && firstError !== undefined) {
|
|
108
248
|
throw firstError;
|
|
109
249
|
}
|
|
250
|
+
// Persist what the server accepted. Written even on the partial path: the
|
|
251
|
+
// files that succeeded really did land, and re-sending them next run is
|
|
252
|
+
// exactly the waste the lock exists to remove.
|
|
253
|
+
if (lock && opts.lockPath && !opts.dryRun)
|
|
254
|
+
writeLock(opts.lockPath, lock);
|
|
110
255
|
return {
|
|
111
256
|
mode,
|
|
112
257
|
pushed,
|
|
258
|
+
skipped,
|
|
259
|
+
...(projectCheck ? { projectCheck } : {}),
|
|
260
|
+
...(mode === 'source'
|
|
261
|
+
? { diff: { added: diffAdded, changed: diffChanged, unchanged: skipped } }
|
|
262
|
+
: {}),
|
|
113
263
|
files,
|
|
114
264
|
jobs,
|
|
115
265
|
...(publishSkipped ? { publishSkipped: true } : {}),
|
|
@@ -47,6 +47,12 @@ export interface StatusResult {
|
|
|
47
47
|
queueUnavailable?: string;
|
|
48
48
|
/** `status <id>`: the id is known neither to the server nor to this machine. */
|
|
49
49
|
notFound?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Set to `purged` when a forced refresh met a 404 for a job we already knew
|
|
52
|
+
* the outcome of. Expected, not a failure: the server keeps finished jobs
|
|
53
|
+
* about an hour, and the recorded verdict stands.
|
|
54
|
+
*/
|
|
55
|
+
retention?: 'purged';
|
|
50
56
|
}
|
|
51
57
|
export interface StatusOptions {
|
|
52
58
|
/** A job id, `list`, or nothing (both of the latter mean "list"). */
|
|
@@ -60,6 +66,15 @@ export interface StatusOptions {
|
|
|
60
66
|
client?: LionRapidApiClient;
|
|
61
67
|
/** Limit the listing to jobs pushed to this server. */
|
|
62
68
|
server?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Re-ask the server even for a receipt that already looks final.
|
|
71
|
+
*
|
|
72
|
+
* The short-circuit is right by default — a terminal state cannot change,
|
|
73
|
+
* and the server forgets a finished job after ~1h. But it is wrong at the
|
|
74
|
+
* one moment that matters: someone standing in front of an empty dashboard
|
|
75
|
+
* saying "it is not there" deserves a fresh answer, not a cached tick.
|
|
76
|
+
*/
|
|
77
|
+
refresh?: boolean;
|
|
63
78
|
}
|
|
64
79
|
/** True for the bare command and the explicit `list` form. */
|
|
65
80
|
export declare function isListRequest(jobId?: string): boolean;
|
package/dist/commands/status.js
CHANGED
|
@@ -41,8 +41,9 @@ async function showJob(jobId, history, historyPath, opts) {
|
|
|
41
41
|
const recorded = history.find((e) => e.id === jobId);
|
|
42
42
|
const base = { mode: 'job', jobs: [], historyPath };
|
|
43
43
|
// Already final: the server cannot change its mind, and after ~1h it has
|
|
44
|
-
// forgotten the job entirely. Answer from the receipt and skip the call
|
|
45
|
-
|
|
44
|
+
// forgotten the job entirely. Answer from the receipt and skip the call —
|
|
45
|
+
// unless the caller explicitly asked for a fresh one.
|
|
46
|
+
if (recorded && isTerminalJobStatus(recorded.status) && !opts.refresh) {
|
|
46
47
|
return { ...base, jobs: [localView(recorded)] };
|
|
47
48
|
}
|
|
48
49
|
if (!opts.client) {
|
|
@@ -63,6 +64,13 @@ async function showJob(jobId, history, historyPath, opts) {
|
|
|
63
64
|
// long enough ago that the server purged it (~1h retention). A job we
|
|
64
65
|
// already knew to be final never reaches here — it short-circuits above.
|
|
65
66
|
if (err instanceof ApiError && err.status === 404) {
|
|
67
|
+
// THE RULE. Past retention a 404 is expected, so a forced refresh of a
|
|
68
|
+
// job we already have a verdict for must KEEP that verdict. Downgrading
|
|
69
|
+
// it to `gone` would make --refresh a way of destroying the only answer
|
|
70
|
+
// that outlives the server's memory.
|
|
71
|
+
if (recorded && isTerminalJobStatus(recorded.status)) {
|
|
72
|
+
return { ...base, jobs: [localView(recorded)], retention: 'purged' };
|
|
73
|
+
}
|
|
66
74
|
if (recorded) {
|
|
67
75
|
persist(historyPath, [{ id: jobId, status: 'gone', checkedAt: new Date().toISOString() }]);
|
|
68
76
|
return { ...base, jobs: [{ ...localView({ ...recorded, status: 'gone' }), source: 'server' }] };
|
|
@@ -86,7 +94,7 @@ async function listJobs(history, historyPath, opts) {
|
|
|
86
94
|
// machine / another checkout".
|
|
87
95
|
return { mode: 'list', jobs: [], historyPath, ...(await queueFallback(opts.client)) };
|
|
88
96
|
}
|
|
89
|
-
const stale = scoped.filter((e) => !isTerminalJobStatus(e.status)).slice(0, MAX_REFRESH);
|
|
97
|
+
const stale = (opts.refresh ? scoped : scoped.filter((e) => !isTerminalJobStatus(e.status))).slice(0, MAX_REFRESH);
|
|
90
98
|
const views = new Map();
|
|
91
99
|
let offline;
|
|
92
100
|
if (opts.client && stale.length > 0) {
|
|
@@ -97,8 +105,11 @@ async function listJobs(history, historyPath, opts) {
|
|
|
97
105
|
return await client.getSyncJob(entry.id);
|
|
98
106
|
}
|
|
99
107
|
catch (err) {
|
|
100
|
-
|
|
101
|
-
|
|
108
|
+
// Same rule as the single-job path: never turn a known verdict into
|
|
109
|
+
// 'gone' just because the server has since forgotten the job.
|
|
110
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
111
|
+
return isTerminalJobStatus(entry.status) ? undefined : 'gone';
|
|
112
|
+
}
|
|
102
113
|
if (err instanceof NetworkError) {
|
|
103
114
|
offline ??= err.message;
|
|
104
115
|
return undefined;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `whoami` — which key am I about to use, and where is it going?
|
|
3
|
+
*
|
|
4
|
+
* The hour lost on 2026-08-07 was spent between a push and a dashboard, not
|
|
5
|
+
* knowing which credential had routed it. `doctor` can answer, but it needs a
|
|
6
|
+
* valid config and a reachable server; this answers from disk alone, so it
|
|
7
|
+
* still works when the thing you are debugging is the config itself.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately OFFLINE. `doctor` is the networked check; duplicating its probe
|
|
10
|
+
* here would make the fast question slow and would fail for the exact users
|
|
11
|
+
* who need it (bad server URL, no connectivity). Nothing here can leak key
|
|
12
|
+
* material: every value goes through {@link maskKey}.
|
|
13
|
+
*
|
|
14
|
+
* NOT YET SHOWN: the project the key resolves to. That needs `projectId` /
|
|
15
|
+
* `projectName` on the server's project-config response (spec C4/S2), which
|
|
16
|
+
* this deployment does not serve yet — inventing a name here would repeat the
|
|
17
|
+
* exact bug the spec is fixing, where a displayed project was never verified.
|
|
18
|
+
*/
|
|
19
|
+
import { type KeyCapability, type KeySource } from '../config.js';
|
|
20
|
+
export interface WhoamiKey {
|
|
21
|
+
source: KeySource;
|
|
22
|
+
/** What this key class can do — the pk_/sk_ line, stated before you hit it. */
|
|
23
|
+
capability: KeyCapability;
|
|
24
|
+
/** `LIONRAPID_API_KEY`, `default`, or the server URL the key is scoped to. */
|
|
25
|
+
scope: string;
|
|
26
|
+
masked: string;
|
|
27
|
+
}
|
|
28
|
+
export interface WhoamiResult {
|
|
29
|
+
/** Absolute path of the lionrapid.yml in play, if there is one. */
|
|
30
|
+
configPath?: string;
|
|
31
|
+
/** The server this directory would talk to. */
|
|
32
|
+
server?: string;
|
|
33
|
+
/** The credential that would be used. Absent when none resolves. */
|
|
34
|
+
key?: WhoamiKey;
|
|
35
|
+
/** Every saved credential, masked — shown even when none of them applies. */
|
|
36
|
+
scopes: {
|
|
37
|
+
scope: string;
|
|
38
|
+
masked: string;
|
|
39
|
+
}[];
|
|
40
|
+
/** Why there is no answer (no config, invalid config, no matching key). */
|
|
41
|
+
problem?: string;
|
|
42
|
+
}
|
|
43
|
+
export interface WhoamiOptions {
|
|
44
|
+
cwd: string;
|
|
45
|
+
config?: string;
|
|
46
|
+
credentialsPath?: string;
|
|
47
|
+
env?: NodeJS.ProcessEnv;
|
|
48
|
+
}
|
|
49
|
+
export declare function runWhoami(opts: WhoamiOptions): WhoamiResult;
|
|
50
|
+
//# sourceMappingURL=whoami.d.ts.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `whoami` — which key am I about to use, and where is it going?
|
|
3
|
+
*
|
|
4
|
+
* The hour lost on 2026-08-07 was spent between a push and a dashboard, not
|
|
5
|
+
* knowing which credential had routed it. `doctor` can answer, but it needs a
|
|
6
|
+
* valid config and a reachable server; this answers from disk alone, so it
|
|
7
|
+
* still works when the thing you are debugging is the config itself.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately OFFLINE. `doctor` is the networked check; duplicating its probe
|
|
10
|
+
* here would make the fast question slow and would fail for the exact users
|
|
11
|
+
* who need it (bad server URL, no connectivity). Nothing here can leak key
|
|
12
|
+
* material: every value goes through {@link maskKey}.
|
|
13
|
+
*
|
|
14
|
+
* NOT YET SHOWN: the project the key resolves to. That needs `projectId` /
|
|
15
|
+
* `projectName` on the server's project-config response (spec C4/S2), which
|
|
16
|
+
* this deployment does not serve yet — inventing a name here would repeat the
|
|
17
|
+
* exact bug the spec is fixing, where a displayed project was never verified.
|
|
18
|
+
*/
|
|
19
|
+
import { ApiKeyError, ConfigError, findConfigPath, loadConfig, listCredentials, maskKey, keyCapability, resolveApiKeyDetailed, } from '../config.js';
|
|
20
|
+
export function runWhoami(opts) {
|
|
21
|
+
const scopes = listCredentials(opts.credentialsPath);
|
|
22
|
+
const result = { scopes };
|
|
23
|
+
// The config is optional: run from anywhere and the saved scopes are still
|
|
24
|
+
// worth seeing. A broken config is reported, not thrown — whoami is what you
|
|
25
|
+
// reach for WHEN something is broken.
|
|
26
|
+
const path = opts.config ?? findConfigPath(opts.cwd);
|
|
27
|
+
if (!path) {
|
|
28
|
+
result.problem = 'no lionrapid.yml here — run `lionrapid init`';
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
result.configPath = path;
|
|
32
|
+
try {
|
|
33
|
+
result.server = loadConfig(path).server;
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
result.problem =
|
|
37
|
+
err instanceof ConfigError
|
|
38
|
+
? `lionrapid.yml is not valid: ${err.issues.map((i) => `${i.path}: ${i.message}`).join('; ') || err.message}`
|
|
39
|
+
: String(err);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const resolved = resolveApiKeyDetailed({
|
|
44
|
+
server: result.server,
|
|
45
|
+
credentialsPath: opts.credentialsPath,
|
|
46
|
+
env: opts.env,
|
|
47
|
+
});
|
|
48
|
+
result.key = {
|
|
49
|
+
source: resolved.source,
|
|
50
|
+
scope: resolved.scope,
|
|
51
|
+
masked: maskKey(resolved.key),
|
|
52
|
+
capability: keyCapability(resolved.key),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
// An unresolvable key is the ANSWER here, not a failure: it is precisely
|
|
57
|
+
// what the user came to find out.
|
|
58
|
+
if (!(err instanceof ApiKeyError))
|
|
59
|
+
throw err;
|
|
60
|
+
result.problem = result.problem
|
|
61
|
+
? `${result.problem}\n${err.message}`
|
|
62
|
+
: err.message;
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=whoami.js.map
|
package/dist/config.d.ts
CHANGED
|
@@ -41,7 +41,22 @@ export interface FileEntry {
|
|
|
41
41
|
/** True when a `files[].source` is a glob pattern rather than a concrete path. */
|
|
42
42
|
export declare function isGlobSource(source: string): boolean;
|
|
43
43
|
export interface LionRapidConfig {
|
|
44
|
-
|
|
44
|
+
/**
|
|
45
|
+
* The project the API key must resolve to — checked on every push.
|
|
46
|
+
*
|
|
47
|
+
* Replaces the old free-text `project:`, which nothing verified. Proven on
|
|
48
|
+
* 2026-08-07: a push declaring `project: cli-verify`, a name that does not
|
|
49
|
+
* exist, landed in the project the KEY belongs to, while the CLI reported
|
|
50
|
+
* the config's label for 30 jobs that went somewhere else. THE API KEY
|
|
51
|
+
* SELECTS THE PROJECT; the config never could.
|
|
52
|
+
*
|
|
53
|
+
* OPTIONAL, because the server field it comes from (`meta.project_uuid`) is
|
|
54
|
+
* real but UNDECLARED — the translations route returns a raw Response to set
|
|
55
|
+
* its ETag header, bypassing the response model, so a later tidy-up could
|
|
56
|
+
* remove it in what looks like a cosmetic change (server task #70). A guard
|
|
57
|
+
* that cannot arm must not block a push.
|
|
58
|
+
*/
|
|
59
|
+
projectId?: string;
|
|
45
60
|
server: string;
|
|
46
61
|
sourceLocale: string;
|
|
47
62
|
targetLocales: string[];
|
|
@@ -71,8 +86,26 @@ export declare class ConfigError extends Error {
|
|
|
71
86
|
export declare class ApiKeyError extends Error {
|
|
72
87
|
constructor(message: string);
|
|
73
88
|
}
|
|
89
|
+
/** Opt-in for sending a key to a host we do not recognize (self-hosted). */
|
|
90
|
+
export declare const ALLOW_UNTRUSTED_HOST_ENV = "LIONRAPID_ALLOW_UNTRUSTED_HOST";
|
|
74
91
|
/** Locate the config file in `cwd`, returning its path or null. */
|
|
75
92
|
export declare function findConfigPath(cwd?: string): string | null;
|
|
93
|
+
/**
|
|
94
|
+
* What a source-only config costs you. Named commands, not a vague "no
|
|
95
|
+
* targets": the point is that nothing is broken — two commands simply have
|
|
96
|
+
* nothing to do until the project has target languages.
|
|
97
|
+
*/
|
|
98
|
+
export declare const NO_TARGETS_WARNING: string;
|
|
99
|
+
/**
|
|
100
|
+
* The warning, but only for the commands it applies to.
|
|
101
|
+
*
|
|
102
|
+
* Emitting it at parse time meant every command carried it — including
|
|
103
|
+
* `status`, which reports what the backend did with a job and has nothing to
|
|
104
|
+
* do with target locales. A warning that fires where it does not apply is how
|
|
105
|
+
* people learn to ignore warnings, so the three commands that are actually
|
|
106
|
+
* affected ask for it and nobody else sees it.
|
|
107
|
+
*/
|
|
108
|
+
export declare function noTargetsNotice(config: LionRapidConfig): string | undefined;
|
|
76
109
|
/** Parse + validate a YAML config string into a typed config. */
|
|
77
110
|
export declare function parseConfig(rawYaml: string): LionRapidConfig;
|
|
78
111
|
/**
|
|
@@ -88,6 +121,19 @@ export declare function parseConfig(rawYaml: string): LionRapidConfig;
|
|
|
88
121
|
export declare function expandGlobEntries(files: FileEntry[], cwd: string, warn?: (message: string) => void): FileEntry[];
|
|
89
122
|
/** Read + parse + validate the config at `path`. */
|
|
90
123
|
export declare function loadConfig(path: string): LionRapidConfig;
|
|
124
|
+
/**
|
|
125
|
+
* The user's home directory, `$HOME` first.
|
|
126
|
+
*
|
|
127
|
+
* Identical to `os.homedir()` for real users on POSIX (it reads `$HOME` too),
|
|
128
|
+
* but a JS-level `process.env.HOME` assignment is invisible to `homedir()`'s
|
|
129
|
+
* native `getenv` inside a worker thread — which is where vitest runs. Without
|
|
130
|
+
* this, the suite reads (and could write) the developer's real
|
|
131
|
+
* `~/.lionrapid/`: a test asserting "no credentials" passes in CI and fails on
|
|
132
|
+
* a laptop that has logged in, which is a silent source of divergence.
|
|
133
|
+
*
|
|
134
|
+
* Shared by {@link defaultCredentialsPath} and the job history.
|
|
135
|
+
*/
|
|
136
|
+
export declare function homeDir(): string;
|
|
91
137
|
/** Where the CLI reads/writes credentials: `~/.lionrapid/credentials`. */
|
|
92
138
|
export declare function defaultCredentialsPath(): string;
|
|
93
139
|
/**
|
|
@@ -116,10 +162,59 @@ export interface ResolveApiKeyOptions {
|
|
|
116
162
|
* 2. credentials file (JSON: `{ "default": "sk_...", "<server>": "sk_..." }`)
|
|
117
163
|
* The key is never read from `lionrapid.yml` (which is committed to git).
|
|
118
164
|
*
|
|
119
|
-
* Trust rule:
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* at an attacker host would
|
|
165
|
+
* Trust rule: a key is only handed out for recognized LionRapid hosts
|
|
166
|
+
* (`*.lionrapid.com`) and localhost. Any other server needs either its own
|
|
167
|
+
* server-scoped credentials entry or {@link ALLOW_UNTRUSTED_HOST_ENV} —
|
|
168
|
+
* otherwise a committed lionrapid.yml pointing at an attacker host would
|
|
169
|
+
* exfiltrate the key of anyone running `push`.
|
|
123
170
|
*/
|
|
124
171
|
export declare function resolveApiKey(opts?: ResolveApiKeyOptions): string;
|
|
172
|
+
/** Where a resolved key came from. `whoami` shows this; nothing else needs it. */
|
|
173
|
+
export type KeySource = 'env' | 'server-scoped' | 'default';
|
|
174
|
+
export interface ResolvedKey {
|
|
175
|
+
key: string;
|
|
176
|
+
source: KeySource;
|
|
177
|
+
/** The env var name, or the credentials entry the key was read from. */
|
|
178
|
+
scope: string;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* {@link resolveApiKey}, plus WHERE the key came from.
|
|
182
|
+
*
|
|
183
|
+
* One implementation, two views: `whoami` has to say which credential is about
|
|
184
|
+
* to be used, and a second copy of this precedence ladder would be a place for
|
|
185
|
+
* the two to drift — the displayed key would stop being the sent key, which is
|
|
186
|
+
* the exact confusion `whoami` exists to end.
|
|
187
|
+
*/
|
|
188
|
+
export declare function resolveApiKeyDetailed(opts?: ResolveApiKeyOptions): ResolvedKey;
|
|
189
|
+
/**
|
|
190
|
+
* Display form of a key: prefix + last four, never the middle.
|
|
191
|
+
*
|
|
192
|
+
* A key shorter than the prefix+last4 window would otherwise be echoed almost
|
|
193
|
+
* whole, so a short (malformed, truncated) value drops the prefix entirely —
|
|
194
|
+
* "mask" must not become "print" for the one input where it matters.
|
|
195
|
+
*/
|
|
196
|
+
export declare function maskKey(key: string): string;
|
|
197
|
+
/** What a key class can and cannot do, in the terms a user actually hits. */
|
|
198
|
+
export interface KeyCapability {
|
|
199
|
+
kind: 'publishable' | 'master' | 'editor-session' | 'unknown';
|
|
200
|
+
/** One line, naming the functional line rather than the scope vocabulary. */
|
|
201
|
+
summary: string;
|
|
202
|
+
/** True when `push --target` / `--publish` will work (needs `targets:write`). */
|
|
203
|
+
canWriteTargets: boolean;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* A key's class, from its prefix.
|
|
207
|
+
*
|
|
208
|
+
* The functional line is `targets:write`, NOT the queue scopes. A `pk_` holder
|
|
209
|
+
* can push sources, pull and read status, but `push --target` dies on a bare
|
|
210
|
+
* 403 from `targets/batch` — hit for real on 2026-08-07 while trying to
|
|
211
|
+
* publish a row, and exactly the kind of thing worth saying BEFORE the request
|
|
212
|
+
* instead of after it.
|
|
213
|
+
*/
|
|
214
|
+
export declare function keyCapability(key: string): KeyCapability;
|
|
215
|
+
/** Every saved credential, as `{ scope, masked }`. Never returns key material. */
|
|
216
|
+
export declare function listCredentials(credentialsPath?: string): {
|
|
217
|
+
scope: string;
|
|
218
|
+
masked: string;
|
|
219
|
+
}[];
|
|
125
220
|
//# sourceMappingURL=config.d.ts.map
|