@lionrapid/cli 0.3.0 → 0.5.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.
@@ -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,58 @@ 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
+ const protectedRows = [];
90
+ const deniedRows = [];
91
+ let serverSkipped = 0;
92
+ /** Rows the server confirmed it wrote (target mode only). */
93
+ let written = 0;
94
+ /** The same, for the file currently being processed. */
95
+ let writtenForFile = 0;
96
+ /**
97
+ * Rows the server said it REFUSED for the current file, summed from its own
98
+ * buckets. Deliberately not (sent − written): a row whose text already
99
+ * matches is written by nobody and refused by nobody — the server leaves it
100
+ * out of every bucket and out of `total` — so the subtraction would report a
101
+ * no-op as a problem. Seen live: 2 sent, `protected: 1`, `total: 1`.
102
+ */
103
+ let refusedForFile = 0;
104
+ // INCREMENTAL. The lock says what the server already has, so a second run
105
+ // sends "4 changed" rather than all 653. No lock (first run, or a fresh
106
+ // checkout) means we know nothing, which is a backfill — send everything.
107
+ //
108
+ // Source mode only: `push --target` sends LOCAL values, and recording those
109
+ // as server state would be the fabrication `pull` is careful to avoid.
110
+ const lock = opts.lockPath && mode === 'source' ? readLock(opts.lockPath) : undefined;
111
+ // One asks "what is the server missing", the other says "ignore all of that
112
+ // and take mine". Silently picking one would be a coin toss with the user's
113
+ // data.
114
+ if (opts.force && opts.backfillMissing) {
115
+ throw new ConfigError('--force and --backfill-missing ask for opposite things: one overwrites every server\n' +
116
+ ' row, the other touches only the rows that are missing. Pick one.');
117
+ }
118
+ // THE GUARD. Runs before a single row is sent, because it is worthless
119
+ // afterwards — on 2026-08-07, 653 strings reported as `tm-frontpage` landed
120
+ // in another project, and nothing said so until an hour of hunting later.
121
+ let projectCheck;
122
+ if (config.projectId) {
123
+ const actual = await resolveKeyProject(client, config);
124
+ if (actual === undefined) {
125
+ projectCheck = 'unavailable';
126
+ }
127
+ else if (actual !== config.projectId) {
128
+ throw new ConfigError('project mismatch — this push would go somewhere else.\n' +
129
+ ` lionrapid.yml projectId: ${config.projectId}\n` +
130
+ ` this API key resolves to: ${actual}\n` +
131
+ ' The API key selects the project; the config cannot. Update projectId in\n' +
132
+ ' lionrapid.yml, or switch keys.');
133
+ }
134
+ else {
135
+ projectCheck = 'ok';
136
+ }
137
+ }
43
138
  let firstError;
44
139
  for (const file of config.files) {
45
140
  const adapter = getAdapter(file.format);
@@ -57,7 +152,58 @@ export async function runPush(config, client, opts) {
57
152
  try {
58
153
  const parseLocale = mode === 'source' ? config.sourceLocale : opts.target;
59
154
  const entries = adapter.parse(readFileSync(absPath, 'utf8'), { locale: parseLocale });
60
- const items = entries.map((e) => ({ key: e.key, value: e.value }));
155
+ const all = entries.map((e) => ({ key: e.key, value: e.value }));
156
+ // Reset per file: the header total is the sum of these, so they have to
157
+ // measure the same thing.
158
+ writtenForFile = 0;
159
+ refusedForFile = 0;
160
+ const hashes = new Map(all.map((it) => [it.key, hashSource(it.value)]));
161
+ // Three modes. `--force` and `--backfill-missing` both ASK THE SERVER
162
+ // rather than trusting the lock, which is what makes them a recovery
163
+ // path: the lock can be wrong (a 202 whose job later died records rows
164
+ // as sent that never landed), and only the server knows the truth.
165
+ let items;
166
+ if (opts.force) {
167
+ const onServer = await sourceRowsOnServer(client, config.sourceLocale, namespace);
168
+ if (!opts.dryRun) {
169
+ saveHistory(opts.lockPath ?? opts.cwd, {
170
+ direction: 'push',
171
+ namespace,
172
+ locale: config.sourceLocale,
173
+ reason: `push --force overwrote these server rows from ${relPath}`,
174
+ entries: all
175
+ .filter((it) => onServer.has(it.key))
176
+ .map((it) => ({ key: it.key, value: onServer.get(it.key) })),
177
+ });
178
+ }
179
+ items = all;
180
+ }
181
+ else if (opts.backfillMissing) {
182
+ const onServer = await sourceRowsOnServer(client, config.sourceLocale, namespace);
183
+ items = all.filter((it) => !onServer.has(it.key));
184
+ }
185
+ else {
186
+ // Default: incremental against the lock — new keys and changed text.
187
+ items = lock
188
+ ? all.filter((it) => lock.entries[lockKey(namespace, it.key)]?.sourceHash !== hashes.get(it.key))
189
+ : all;
190
+ }
191
+ skipped += all.length - items.length;
192
+ // A key the lock has never seen is NEW; one it has seen with a different
193
+ // hash is CHANGED. Counted from the lock even under --force, so the
194
+ // report describes the repo's relationship to the server rather than
195
+ // just restating the flag.
196
+ for (const it of items) {
197
+ const known = lock?.entries[lockKey(namespace, it.key)]?.sourceHash;
198
+ if (known === undefined || known === '')
199
+ diffAdded += 1;
200
+ else
201
+ diffChanged += 1;
202
+ }
203
+ if (items.length === 0) {
204
+ files.push({ file: relPath, count: 0, unchanged: all.length });
205
+ continue;
206
+ }
61
207
  // Dry run: parse + count only, never touch the network.
62
208
  if (!opts.dryRun) {
63
209
  for (const batch of chunk(items, BATCH)) {
@@ -80,17 +226,49 @@ export async function runPush(config, client, opts) {
80
226
  }
81
227
  }
82
228
  else {
83
- await client.targetsBatch(opts.target, namespace, batch);
229
+ const outcome = await client.targetsBatch(opts.target, namespace, batch);
230
+ protectedRows.push(...outcome.protected_rows);
231
+ deniedRows.push(...outcome.denied_rows);
232
+ serverSkipped += outcome.skipped;
233
+ // Count what the SERVER says it wrote, not what we put on the
234
+ // wire. A refused row is not a pushed row — the whole point of
235
+ // this response is that "sent" and "stored" can differ.
236
+ written += outcome.created + outcome.updated;
237
+ writtenForFile += outcome.created + outcome.updated;
238
+ refusedForFile +=
239
+ outcome.protected + outcome.denied + outcome.skipped + outcome.rejected;
84
240
  if (canPublish) {
241
+ // Never mark-ready a row the server declined to write: it would
242
+ // publish whatever is already there under the guise of this
243
+ // push, which is worse than the refusal it followed.
244
+ const refused = new Set([...outcome.protected_rows, ...outcome.denied_rows].map((r) => r.key));
85
245
  for (const it of batch) {
246
+ if (refused.has(`${opts.target}:${namespace}:${it.key}`))
247
+ continue;
86
248
  await client.markReady(opts.target, namespace, it.key);
87
249
  }
88
250
  }
89
251
  }
90
252
  }
91
253
  }
92
- pushed += items.length;
93
- files.push({ file: relPath, count: items.length });
254
+ // Record ONLY what the server accepted, and only for a file that got
255
+ // through cleanly — a hash written for rows the server never took would
256
+ // make the next push skip them silently.
257
+ if (lock && !opts.dryRun) {
258
+ for (const it of items) {
259
+ const key = lockKey(namespace, it.key);
260
+ lock.entries[key] = { ...lock.entries[key], sourceHash: hashes.get(it.key) };
261
+ }
262
+ }
263
+ pushed += mode === 'target' ? 0 : items.length;
264
+ files.push({
265
+ file: relPath,
266
+ // Target mode reports what the SERVER wrote; source mode reports what
267
+ // was queued, which is all a 202 can honestly claim.
268
+ count: mode === 'target' ? writtenForFile : items.length,
269
+ ...(mode === 'target' && refusedForFile > 0 ? { refused: refusedForFile } : {}),
270
+ ...(all.length > items.length ? { unchanged: all.length - items.length } : {}),
271
+ });
94
272
  }
95
273
  catch (err) {
96
274
  firstError ??= err;
@@ -107,9 +285,24 @@ export async function runPush(config, client, opts) {
107
285
  if (attempted.length > 0 && attempted.every((f) => f.error) && firstError !== undefined) {
108
286
  throw firstError;
109
287
  }
288
+ // Persist what the server accepted. Written even on the partial path: the
289
+ // files that succeeded really did land, and re-sending them next run is
290
+ // exactly the waste the lock exists to remove.
291
+ if (lock && opts.lockPath && !opts.dryRun)
292
+ writeLock(opts.lockPath, lock);
110
293
  return {
111
294
  mode,
112
- pushed,
295
+ // Target mode reports what the server confirmed it WROTE. Source mode
296
+ // reports what was queued, which is all a 202 can honestly claim.
297
+ pushed: mode === 'target' ? written : pushed,
298
+ skipped,
299
+ protected: protectedRows,
300
+ denied: deniedRows,
301
+ serverSkipped,
302
+ ...(projectCheck ? { projectCheck } : {}),
303
+ ...(mode === 'source'
304
+ ? { diff: { added: diffAdded, changed: diffChanged, unchanged: skipped } }
305
+ : {}),
113
306
  files,
114
307
  jobs,
115
308
  ...(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;
@@ -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
- if (recorded && isTerminalJobStatus(recorded.status)) {
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
- if (err instanceof ApiError && err.status === 404)
101
- return 'gone';
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
- project: string;
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: the `default` credential is only handed out for recognized
120
- * LionRapid hosts (`*.lionrapid.com`) and localhost. Any other server needs
121
- * its own server-scoped entry — otherwise a committed lionrapid.yml pointing
122
- * at an attacker host would exfiltrate the key of anyone running `push`.
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