@lionrapid/cli 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +123 -0
- package/README.md +15 -1
- package/dist/api-client.d.ts +129 -1
- package/dist/api-client.js +139 -2
- package/dist/cli.js +469 -34
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +47 -5
- package/dist/commands/init.d.ts +73 -2
- package/dist/commands/init.js +0 -0
- package/dist/commands/login.d.ts +50 -4
- package/dist/commands/login.js +70 -16
- package/dist/commands/pull.d.ts +15 -1
- package/dist/commands/pull.js +92 -4
- package/dist/commands/push.d.ts +73 -0
- package/dist/commands/push.js +175 -3
- package/dist/commands/status.d.ts +91 -0
- package/dist/commands/status.js +232 -0
- package/dist/commands/whoami.d.ts +50 -0
- package/dist/commands/whoami.js +66 -0
- package/dist/config.d.ts +100 -5
- package/dist/config.js +239 -22
- package/dist/history.d.ts +44 -0
- package/dist/history.js +65 -0
- package/dist/job-history.d.ts +106 -0
- package/dist/job-history.js +163 -0
- package/dist/lockfile.d.ts +90 -0
- package/dist/lockfile.js +110 -0
- package/dist/time.d.ts +27 -0
- package/dist/time.js +53 -0
- package/package.json +1 -1
package/dist/commands/push.js
CHANGED
|
@@ -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
|
|
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({
|
|
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
|