@lionrapid/cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,10 +17,53 @@ import { existsSync, readFileSync } from 'node:fs';
17
17
  import { resolve } from 'node:path';
18
18
  import { ConfigError } from '../config.js';
19
19
  import { getAdapter } from '../adapters/index.js';
20
+ import { saveHistory } from '../history.js';
21
+ import { hashSource, lockKey, readLock, writeLock } from '../lockfile.js';
20
22
  import { chunk } from './context.js';
21
23
  // The server's enforced ceiling is 1000 items per sync/targets batch (Pydantic
22
24
  // `max_length` on the request model) — 500 stays comfortably under it.
23
25
  const BATCH = 500;
26
+ /**
27
+ * The source rows the server actually holds for a namespace.
28
+ *
29
+ * A locked or missing namespace answers "nothing", which is the right reading
30
+ * for both callers: `--backfill-missing` then sends everything, and
31
+ * `--force` has nothing to snapshot.
32
+ */
33
+ async function sourceRowsOnServer(client, sourceLocale, namespace) {
34
+ try {
35
+ const bundle = await client.getNamespace(sourceLocale, namespace);
36
+ return new Map(Object.entries(bundle.translations));
37
+ }
38
+ catch {
39
+ return new Map();
40
+ }
41
+ }
42
+ /**
43
+ * The project this API KEY resolves to, or undefined when the server does not
44
+ * say.
45
+ *
46
+ * Read from `meta.project_uuid` on a translations fetch. That field is real
47
+ * but UNDECLARED — the route returns a raw Response to set its ETag header,
48
+ * bypassing the declared response model (server task #70) — so "absent" must
49
+ * stay a normal, non-blocking answer forever, not an error. A locked (423) or
50
+ * missing namespace also answers nothing, which is right: a project with no
51
+ * rows yet cannot identify itself this way.
52
+ */
53
+ async function resolveKeyProject(client, config) {
54
+ const namespaces = new Set(config.files.map((f) => f.namespace ?? config.namespace));
55
+ for (const namespace of namespaces) {
56
+ try {
57
+ const bundle = await client.getNamespace(config.sourceLocale, namespace);
58
+ if (bundle.projectId)
59
+ return bundle.projectId;
60
+ }
61
+ catch {
62
+ // Try the next namespace; an unreachable server is the caller's problem.
63
+ }
64
+ }
65
+ return undefined;
66
+ }
24
67
  export async function runPush(config, client, opts) {
25
68
  const mode = opts.target ? 'target' : 'source';
26
69
  // The backend does NOT validate target locales — `targets/batch` silently
@@ -40,6 +83,43 @@ export async function runPush(config, client, opts) {
40
83
  const canPublish = mode === 'target' && opts.publish === true && client.canPublish;
41
84
  const publishSkipped = mode === 'target' && opts.publish === true && !client.canPublish;
42
85
  let pushed = 0;
86
+ let skipped = 0;
87
+ let diffAdded = 0;
88
+ let diffChanged = 0;
89
+ // INCREMENTAL. The lock says what the server already has, so a second run
90
+ // sends "4 changed" rather than all 653. No lock (first run, or a fresh
91
+ // checkout) means we know nothing, which is a backfill — send everything.
92
+ //
93
+ // Source mode only: `push --target` sends LOCAL values, and recording those
94
+ // as server state would be the fabrication `pull` is careful to avoid.
95
+ const lock = opts.lockPath && mode === 'source' ? readLock(opts.lockPath) : undefined;
96
+ // One asks "what is the server missing", the other says "ignore all of that
97
+ // and take mine". Silently picking one would be a coin toss with the user's
98
+ // data.
99
+ if (opts.force && opts.backfillMissing) {
100
+ throw new ConfigError('--force and --backfill-missing ask for opposite things: one overwrites every server\n' +
101
+ ' row, the other touches only the rows that are missing. Pick one.');
102
+ }
103
+ // THE GUARD. Runs before a single row is sent, because it is worthless
104
+ // afterwards — on 2026-08-07, 653 strings reported as `tm-frontpage` landed
105
+ // in another project, and nothing said so until an hour of hunting later.
106
+ let projectCheck;
107
+ if (config.projectId) {
108
+ const actual = await resolveKeyProject(client, config);
109
+ if (actual === undefined) {
110
+ projectCheck = 'unavailable';
111
+ }
112
+ else if (actual !== config.projectId) {
113
+ throw new ConfigError('project mismatch — this push would go somewhere else.\n' +
114
+ ` lionrapid.yml projectId: ${config.projectId}\n` +
115
+ ` this API key resolves to: ${actual}\n` +
116
+ ' The API key selects the project; the config cannot. Update projectId in\n' +
117
+ ' lionrapid.yml, or switch keys.');
118
+ }
119
+ else {
120
+ projectCheck = 'ok';
121
+ }
122
+ }
43
123
  let firstError;
44
124
  for (const file of config.files) {
45
125
  const adapter = getAdapter(file.format);
@@ -57,7 +137,54 @@ export async function runPush(config, client, opts) {
57
137
  try {
58
138
  const parseLocale = mode === 'source' ? config.sourceLocale : opts.target;
59
139
  const entries = adapter.parse(readFileSync(absPath, 'utf8'), { locale: parseLocale });
60
- const 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
+ }
61
188
  // Dry run: parse + count only, never touch the network.
62
189
  if (!opts.dryRun) {
63
190
  for (const batch of chunk(items, BATCH)) {
@@ -89,8 +216,21 @@ export async function runPush(config, client, opts) {
89
216
  }
90
217
  }
91
218
  }
219
+ // Record ONLY what the server accepted, and only for a file that got
220
+ // through cleanly — a hash written for rows the server never took would
221
+ // make the next push skip them silently.
222
+ if (lock && !opts.dryRun) {
223
+ for (const it of items) {
224
+ const key = lockKey(namespace, it.key);
225
+ lock.entries[key] = { ...lock.entries[key], sourceHash: hashes.get(it.key) };
226
+ }
227
+ }
92
228
  pushed += items.length;
93
- files.push({ 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
+ });
94
234
  }
95
235
  catch (err) {
96
236
  firstError ??= err;
@@ -107,9 +247,19 @@ export async function runPush(config, client, opts) {
107
247
  if (attempted.length > 0 && attempted.every((f) => f.error) && firstError !== undefined) {
108
248
  throw firstError;
109
249
  }
250
+ // Persist what the server accepted. Written even on the partial path: the
251
+ // files that succeeded really did land, and re-sending them next run is
252
+ // exactly the waste the lock exists to remove.
253
+ if (lock && opts.lockPath && !opts.dryRun)
254
+ writeLock(opts.lockPath, lock);
110
255
  return {
111
256
  mode,
112
257
  pushed,
258
+ skipped,
259
+ ...(projectCheck ? { projectCheck } : {}),
260
+ ...(mode === 'source'
261
+ ? { diff: { added: diffAdded, changed: diffChanged, unchanged: skipped } }
262
+ : {}),
113
263
  files,
114
264
  jobs,
115
265
  ...(publishSkipped ? { publishSkipped: true } : {}),
@@ -47,6 +47,12 @@ export interface StatusResult {
47
47
  queueUnavailable?: string;
48
48
  /** `status <id>`: the id is known neither to the server nor to this machine. */
49
49
  notFound?: boolean;
50
+ /**
51
+ * Set to `purged` when a forced refresh met a 404 for a job we already knew
52
+ * the outcome of. Expected, not a failure: the server keeps finished jobs
53
+ * about an hour, and the recorded verdict stands.
54
+ */
55
+ retention?: 'purged';
50
56
  }
51
57
  export interface StatusOptions {
52
58
  /** A job id, `list`, or nothing (both of the latter mean "list"). */
@@ -60,6 +66,15 @@ export interface StatusOptions {
60
66
  client?: LionRapidApiClient;
61
67
  /** Limit the listing to jobs pushed to this server. */
62
68
  server?: string;
69
+ /**
70
+ * Re-ask the server even for a receipt that already looks final.
71
+ *
72
+ * The short-circuit is right by default — a terminal state cannot change,
73
+ * and the server forgets a finished job after ~1h. But it is wrong at the
74
+ * one moment that matters: someone standing in front of an empty dashboard
75
+ * saying "it is not there" deserves a fresh answer, not a cached tick.
76
+ */
77
+ refresh?: boolean;
63
78
  }
64
79
  /** True for the bare command and the explicit `list` form. */
65
80
  export declare function isListRequest(jobId?: string): boolean;
@@ -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