@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.
@@ -5,6 +5,11 @@
5
5
  * rows via `sync/batch`; the server then queues machine translation for the
6
6
  * configured target locales.
7
7
  *
8
+ * `sync/batch` answers 202 Accepted — the rows are QUEUED, not yet stored. So
9
+ * every batch also yields job ids ({@link PushedJob}), which is the only thing
10
+ * that lets a caller tell "the backend stored this" from "the backend dropped
11
+ * it". Without them a push that dead-letters looks exactly like one that worked.
12
+ *
8
13
  * `--target <locale>` mode uploads an already-translated target file as drafts
9
14
  * via `targets/batch`, optionally publishing them with `--publish`.
10
15
  */
@@ -12,10 +17,53 @@ import { existsSync, readFileSync } from 'node:fs';
12
17
  import { resolve } from 'node:path';
13
18
  import { ConfigError } from '../config.js';
14
19
  import { getAdapter } from '../adapters/index.js';
20
+ import { saveHistory } from '../history.js';
21
+ import { hashSource, lockKey, readLock, writeLock } from '../lockfile.js';
15
22
  import { chunk } from './context.js';
16
23
  // The server's enforced ceiling is 1000 items per sync/targets batch (Pydantic
17
24
  // `max_length` on the request model) — 500 stays comfortably under it.
18
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
+ }
19
67
  export async function runPush(config, client, opts) {
20
68
  const mode = opts.target ? 'target' : 'source';
21
69
  // The backend does NOT validate target locales — `targets/batch` silently
@@ -29,11 +77,49 @@ export async function runPush(config, client, opts) {
29
77
  'it in the dashboard before pushing target translations.');
30
78
  }
31
79
  const files = [];
80
+ const jobs = [];
32
81
  // Publishing (mark-ready) needs an editor token; if asked to publish without
33
82
  // one, we still upload the drafts and report that publishing was skipped.
34
83
  const canPublish = mode === 'target' && opts.publish === true && client.canPublish;
35
84
  const publishSkipped = mode === 'target' && opts.publish === true && !client.canPublish;
36
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
+ }
37
123
  let firstError;
38
124
  for (const file of config.files) {
39
125
  const adapter = getAdapter(file.format);
@@ -51,12 +137,74 @@ export async function runPush(config, client, opts) {
51
137
  try {
52
138
  const parseLocale = mode === 'source' ? config.sourceLocale : opts.target;
53
139
  const entries = adapter.parse(readFileSync(absPath, 'utf8'), { locale: parseLocale });
54
- const items = entries.map((e) => ({ key: e.key, value: e.value }));
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
+ }
55
188
  // Dry run: parse + count only, never touch the network.
56
189
  if (!opts.dryRun) {
57
190
  for (const batch of chunk(items, BATCH)) {
58
191
  if (mode === 'source') {
59
- await client.syncBatch(config.sourceLocale, namespace, batch);
192
+ const queued = await client.syncBatch(config.sourceLocale, namespace, batch);
193
+ // Every id, not just job_id: a batch spanning several locales queues
194
+ // one job each, and the ones after the first would otherwise be
195
+ // unobservable. Reported as they happen so a later total failure
196
+ // cannot take the earlier receipts down with it.
197
+ for (const id of queued.job_ids) {
198
+ const job = {
199
+ id,
200
+ file: relPath,
201
+ namespace,
202
+ keys: batch.length,
203
+ status: queued.status || 'pending',
204
+ };
205
+ jobs.push(job);
206
+ opts.onJob?.(job);
207
+ }
60
208
  }
61
209
  else {
62
210
  await client.targetsBatch(opts.target, namespace, batch);
@@ -68,8 +216,21 @@ export async function runPush(config, client, opts) {
68
216
  }
69
217
  }
70
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
+ }
71
228
  pushed += items.length;
72
- files.push({ file: relPath, count: items.length });
229
+ files.push({
230
+ file: relPath,
231
+ count: items.length,
232
+ ...(all.length > items.length ? { unchanged: all.length - items.length } : {}),
233
+ });
73
234
  }
74
235
  catch (err) {
75
236
  firstError ??= err;
@@ -86,10 +247,21 @@ export async function runPush(config, client, opts) {
86
247
  if (attempted.length > 0 && attempted.every((f) => f.error) && firstError !== undefined) {
87
248
  throw firstError;
88
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);
89
255
  return {
90
256
  mode,
91
257
  pushed,
258
+ skipped,
259
+ ...(projectCheck ? { projectCheck } : {}),
260
+ ...(mode === 'source'
261
+ ? { diff: { added: diffAdded, changed: diffChanged, unchanged: skipped } }
262
+ : {}),
92
263
  files,
264
+ jobs,
93
265
  ...(publishSkipped ? { publishSkipped: true } : {}),
94
266
  ...(opts.dryRun ? { dryRun: true } : {}),
95
267
  };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `status` — answer the question a 202 leaves open: did the backend actually
3
+ * store what `push` sent?
4
+ *
5
+ * `push` reports what the CLI put on the wire. The server queues that work and
6
+ * answers 202, so a clean push is still compatible with the job failing later.
7
+ * This command closes the loop three ways:
8
+ *
9
+ * status <job-id> → GET /sync/jobs/{id}, the server's own verdict
10
+ * status [list] → every job this machine pushed, newest first
11
+ * (nothing local) → fall back to queue-health + the dead-letter queue
12
+ *
13
+ * Everything degrades. With no network, the local history is still shown and
14
+ * every line is marked as last-known rather than current; with no config or API
15
+ * key (running from some other directory) the disk view still works.
16
+ */
17
+ import { type LionRapidApiClient, type QueueHealth, type SyncJobStatus } from '../api-client.js';
18
+ import { type JobHistoryEntry } from '../job-history.js';
19
+ /** Where a line's status came from — the difference between "is" and "was". */
20
+ export type StatusSource =
21
+ /** Fetched from the server just now. */
22
+ 'server'
23
+ /** Read from `~/.lionrapid/jobs.json` — last known, not current. */
24
+ | 'local'
25
+ /** Terminal on disk: last known IS current, the server cannot change it. */
26
+ | 'local-final';
27
+ export interface JobView {
28
+ id: string;
29
+ status: string;
30
+ source: StatusSource;
31
+ /** What this machine recorded when it pushed (absent for a server-only job). */
32
+ history?: JobHistoryEntry;
33
+ /** The server's answer (absent when offline, or when answered from disk). */
34
+ job?: SyncJobStatus;
35
+ }
36
+ export interface StatusResult {
37
+ mode: 'job' | 'list';
38
+ jobs: JobView[];
39
+ /** Path of the history file the listing came from. */
40
+ historyPath: string;
41
+ /** Set when the server could not be reached — everything shown is last-known. */
42
+ offline?: string;
43
+ /** Queue fallback, only when there is nothing local to show. */
44
+ queue?: QueueHealth;
45
+ deadLettered?: SyncJobStatus[];
46
+ /** Why the queue fallback is missing (e.g. the key lacks `ops:queue`). */
47
+ queueUnavailable?: string;
48
+ /** `status <id>`: the id is known neither to the server nor to this machine. */
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';
56
+ }
57
+ export interface StatusOptions {
58
+ /** A job id, `list`, or nothing (both of the latter mean "list"). */
59
+ jobId?: string;
60
+ /** Override the history file (tests; also keeps a push's receipts together). */
61
+ historyPath?: string;
62
+ /**
63
+ * The API client. Omitted when there is no config/API key here — the command
64
+ * then reports from disk only.
65
+ */
66
+ client?: LionRapidApiClient;
67
+ /** Limit the listing to jobs pushed to this server. */
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;
78
+ }
79
+ /** True for the bare command and the explicit `list` form. */
80
+ export declare function isListRequest(jobId?: string): boolean;
81
+ export declare function runStatus(opts?: StatusOptions): Promise<StatusResult>;
82
+ /**
83
+ * One-line verdict for a job state, in the terms the incident was about: were
84
+ * these rows stored or not. Shared by the detail and list renderings.
85
+ */
86
+ export declare function explainStatus(view: JobView): {
87
+ icon: string;
88
+ label: string;
89
+ detail: string;
90
+ };
91
+ //# sourceMappingURL=status.d.ts.map
@@ -0,0 +1,232 @@
1
+ /**
2
+ * `status` — answer the question a 202 leaves open: did the backend actually
3
+ * store what `push` sent?
4
+ *
5
+ * `push` reports what the CLI put on the wire. The server queues that work and
6
+ * answers 202, so a clean push is still compatible with the job failing later.
7
+ * This command closes the loop three ways:
8
+ *
9
+ * status <job-id> → GET /sync/jobs/{id}, the server's own verdict
10
+ * status [list] → every job this machine pushed, newest first
11
+ * (nothing local) → fall back to queue-health + the dead-letter queue
12
+ *
13
+ * Everything degrades. With no network, the local history is still shown and
14
+ * every line is marked as last-known rather than current; with no config or API
15
+ * key (running from some other directory) the disk view still works.
16
+ */
17
+ import { ApiError, NetworkError, } from '../api-client.js';
18
+ import { defaultJobHistoryPath, isTerminalJobStatus, readJobHistory, updateJobStatuses, } from '../job-history.js';
19
+ import { mapLimit } from './context.js';
20
+ /**
21
+ * How many stale (non-terminal) jobs a bare `status` re-checks against the
22
+ * server. A listing is a glance, not a poll loop: the newest unresolved jobs
23
+ * are the ones anyone is actually waiting on, and the rest still print from
24
+ * disk — labelled as such, never silently stale.
25
+ */
26
+ const MAX_REFRESH = 20;
27
+ const REFRESH_CONCURRENCY = 4;
28
+ /** True for the bare command and the explicit `list` form. */
29
+ export function isListRequest(jobId) {
30
+ return jobId === undefined || jobId === 'list';
31
+ }
32
+ export async function runStatus(opts = {}) {
33
+ const historyPath = opts.historyPath ?? defaultJobHistoryPath();
34
+ const history = readJobHistory(historyPath);
35
+ return isListRequest(opts.jobId)
36
+ ? listJobs(history, historyPath, opts)
37
+ : showJob(opts.jobId, history, historyPath, opts);
38
+ }
39
+ /** `status <job-id>` — the server's verdict, or the local receipt if it can't answer. */
40
+ async function showJob(jobId, history, historyPath, opts) {
41
+ const recorded = history.find((e) => e.id === jobId);
42
+ const base = { mode: 'job', jobs: [], historyPath };
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
+ // unless the caller explicitly asked for a fresh one.
46
+ if (recorded && isTerminalJobStatus(recorded.status) && !opts.refresh) {
47
+ return { ...base, jobs: [localView(recorded)] };
48
+ }
49
+ if (!opts.client) {
50
+ // No client at all (no config or no key here). The receipt is all there is.
51
+ return recorded
52
+ ? { ...base, jobs: [localView(recorded)], offline: 'no API key or project config here' }
53
+ : { ...base, notFound: true, offline: 'no API key or project config here' };
54
+ }
55
+ try {
56
+ const job = await opts.client.getSyncJob(jobId);
57
+ if (recorded) {
58
+ persist(historyPath, [patchFor(job)]);
59
+ }
60
+ return { ...base, jobs: [{ id: jobId, status: job.status, source: 'server', history: recorded, job }] };
61
+ }
62
+ catch (err) {
63
+ // A 404 is an answer, not a failure: the id is unknown, or the job finished
64
+ // long enough ago that the server purged it (~1h retention). A job we
65
+ // already knew to be final never reaches here — it short-circuits above.
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
+ }
74
+ if (recorded) {
75
+ persist(historyPath, [{ id: jobId, status: 'gone', checkedAt: new Date().toISOString() }]);
76
+ return { ...base, jobs: [{ ...localView({ ...recorded, status: 'gone' }), source: 'server' }] };
77
+ }
78
+ return { ...base, notFound: true };
79
+ }
80
+ if (!(err instanceof NetworkError))
81
+ throw err;
82
+ // Unreachable server: show the receipt, clearly marked as last-known.
83
+ if (!recorded)
84
+ return { ...base, notFound: true, offline: err.message };
85
+ return { ...base, jobs: [localView(recorded)], offline: err.message };
86
+ }
87
+ }
88
+ /** Bare `status` / `status list` — the local job log, refreshed where it matters. */
89
+ async function listJobs(history, historyPath, opts) {
90
+ const scoped = opts.server ? history.filter((e) => e.server === opts.server) : history;
91
+ if (scoped.length === 0) {
92
+ // Nothing local — ask the server what its queue looks like instead of
93
+ // shrugging. This is also the honest answer for "I pushed from another
94
+ // machine / another checkout".
95
+ return { mode: 'list', jobs: [], historyPath, ...(await queueFallback(opts.client)) };
96
+ }
97
+ const stale = (opts.refresh ? scoped : scoped.filter((e) => !isTerminalJobStatus(e.status))).slice(0, MAX_REFRESH);
98
+ const views = new Map();
99
+ let offline;
100
+ if (opts.client && stale.length > 0) {
101
+ const client = opts.client;
102
+ const patches = [];
103
+ const refreshed = await mapLimit(stale, REFRESH_CONCURRENCY, async (entry) => {
104
+ try {
105
+ return await client.getSyncJob(entry.id);
106
+ }
107
+ catch (err) {
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
+ }
113
+ if (err instanceof NetworkError) {
114
+ offline ??= err.message;
115
+ return undefined;
116
+ }
117
+ throw err;
118
+ }
119
+ });
120
+ refreshed.forEach((job, i) => {
121
+ const entry = stale[i];
122
+ if (job === undefined)
123
+ return;
124
+ if (job === 'gone') {
125
+ patches.push({ id: entry.id, status: 'gone', checkedAt: new Date().toISOString() });
126
+ views.set(entry.id, { id: entry.id, status: 'gone', source: 'server', history: entry });
127
+ return;
128
+ }
129
+ patches.push(patchFor(job));
130
+ views.set(entry.id, { id: entry.id, status: job.status, source: 'server', history: entry, job });
131
+ });
132
+ persist(historyPath, patches);
133
+ }
134
+ else if (!opts.client) {
135
+ offline = 'no API key or project config here';
136
+ }
137
+ const jobs = scoped.map((entry) => views.get(entry.id) ?? localView(entry));
138
+ return { mode: 'list', jobs, historyPath, ...(offline ? { offline } : {}) };
139
+ }
140
+ /** Queue-wide view for when this machine has no receipts to show. */
141
+ async function queueFallback(client) {
142
+ if (!client)
143
+ return { queueUnavailable: 'no API key or project config here' };
144
+ try {
145
+ const [queue, deadLettered] = await Promise.all([
146
+ client.getQueueHealth(),
147
+ client.getDeadLetterQueue(),
148
+ ]);
149
+ return { queue, deadLettered };
150
+ }
151
+ catch (err) {
152
+ if (err instanceof NetworkError)
153
+ return { offline: err.message };
154
+ // 403 is the common one: queue introspection needs the `ops:queue` scope,
155
+ // which publishable pk_ keys do not carry.
156
+ if (err instanceof ApiError) {
157
+ return {
158
+ queueUnavailable: err.status === 403
159
+ ? 'this API key may not read the queue (needs a master key)'
160
+ : `server returned ${err.status}`,
161
+ };
162
+ }
163
+ throw err;
164
+ }
165
+ }
166
+ function localView(entry) {
167
+ return {
168
+ id: entry.id,
169
+ status: entry.status,
170
+ source: isTerminalJobStatus(entry.status) ? 'local-final' : 'local',
171
+ history: entry,
172
+ };
173
+ }
174
+ function patchFor(job) {
175
+ return {
176
+ id: job.jobId,
177
+ status: job.status,
178
+ checkedAt: new Date().toISOString(),
179
+ error: job.error,
180
+ deadLetterReason: job.deadLetterReason,
181
+ };
182
+ }
183
+ /** History writes are best-effort — a read-only home must not fail `status`. */
184
+ function persist(historyPath, patches) {
185
+ if (patches.length > 0)
186
+ updateJobStatuses(patches, historyPath);
187
+ }
188
+ /**
189
+ * One-line verdict for a job state, in the terms the incident was about: were
190
+ * these rows stored or not. Shared by the detail and list renderings.
191
+ */
192
+ export function explainStatus(view) {
193
+ const dead = view.job?.isDeadLettered === true || view.status === 'failed';
194
+ switch (true) {
195
+ case dead:
196
+ return {
197
+ icon: '✖',
198
+ label: 'dead-lettered',
199
+ detail: 'the backend gave up — these rows were NOT stored',
200
+ };
201
+ case view.status === 'completed':
202
+ return { icon: '✓', label: 'completed', detail: storedDetail(view) };
203
+ case view.status === 'retry_pending':
204
+ return {
205
+ icon: '⚠',
206
+ label: 'retrying',
207
+ detail: 'a previous attempt failed; the backend will try again',
208
+ };
209
+ case view.status === 'processing':
210
+ return { icon: '·', label: 'processing', detail: 'the backend is storing these rows now' };
211
+ case view.status === 'pending':
212
+ return { icon: '·', label: 'queued', detail: 'accepted, not stored yet' };
213
+ case view.status === 'gone':
214
+ return {
215
+ icon: '?',
216
+ label: 'gone',
217
+ detail: 'the server no longer has this job — finished jobs are kept ~1h',
218
+ };
219
+ default:
220
+ return { icon: '?', label: view.status || 'unknown', detail: '' };
221
+ }
222
+ }
223
+ /** "128 key(s) stored (5 added, 3 updated)" when the server reported counts. */
224
+ function storedDetail(view) {
225
+ const result = view.job?.result;
226
+ const added = typeof result?.['keys_added'] === 'number' ? result['keys_added'] : undefined;
227
+ const updated = typeof result?.['keys_updated'] === 'number' ? result['keys_updated'] : undefined;
228
+ if (added === undefined && updated === undefined)
229
+ return 'the rows were stored';
230
+ return `stored — ${added ?? 0} added, ${updated ?? 0} updated`;
231
+ }
232
+ //# sourceMappingURL=status.js.map
@@ -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