@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.
@@ -8,7 +8,7 @@
8
8
  * action only supplies overrides and prints next steps.
9
9
  */
10
10
  import type { FileFormat } from '../config.js';
11
- import type { LionRapidApiClient } from '../api-client.js';
11
+ import type { LionRapidApiClient, FetchImpl } from '../api-client.js';
12
12
  /**
13
13
  * Harvest the target locales a repo *already has* by matching the detected
14
14
  * `target` patterns against real files — so `init` can scaffold what's on
@@ -39,6 +39,65 @@ export type LocaleDefaults = {
39
39
  * `init` keeps working fully offline.
40
40
  */
41
41
  export declare function fetchLocaleDefaults(client: Pick<LionRapidApiClient, 'getProjectConfig'>): Promise<LocaleDefaults>;
42
+ export interface EnsureKeyOptions {
43
+ server: string;
44
+ /** A key supplied on the command line, if any. */
45
+ key?: string;
46
+ credentialsPath?: string;
47
+ /** Whether we may prompt. False in CI, pipes, agent runs. */
48
+ isTTY: boolean;
49
+ /**
50
+ * Which credentials entry a newly typed key lands in. Mirrors `login`'s own
51
+ * rule: `--server` given means a scoped entry, omitted means `default` —
52
+ * "my LionRapid account key". Two commands that both save credentials must
53
+ * not disagree about where they go.
54
+ */
55
+ scope?: 'default' | 'server';
56
+ /** Injectable hidden prompt (defaults to the muted readline one). */
57
+ promptKey?: () => Promise<string>;
58
+ fetchImpl?: FetchImpl;
59
+ /**
60
+ * Environment to resolve `LIONRAPID_API_KEY` from (defaults to
61
+ * `process.env`).
62
+ *
63
+ * Explicit because the ambient environment is NOT a constant: the monorepo's
64
+ * full-suite runner (lerna → nx) loads the repo's tracked `.env`, which sets
65
+ * `LIONRAPID_API_KEY`. Tests that leave this to `process.env` therefore pass
66
+ * under `npm test -w packages/cli` and fail under `npm test` — a difference
67
+ * nobody sees until a pre-merge gate runs the whole suite.
68
+ */
69
+ env?: NodeJS.ProcessEnv;
70
+ }
71
+ /**
72
+ * Get a usable API key, prompting and SAVING one if there is none.
73
+ *
74
+ * This is what collapses onboarding from three commands to one. `init` already
75
+ * prompted for a key — and then threw it away, using it only to prefill
76
+ * locales. You typed the same key into `login` and re-ran `init`. It is now
77
+ * verified and persisted through the same path `login` uses, so the key you
78
+ * just typed is the key the next command finds.
79
+ *
80
+ * A missing key is NOT refused the way a missing server is (C9): inventing a
81
+ * server writes a dead host into a committed file, whereas no key just means
82
+ * `init` cannot prefill locales — the config it writes is still correct.
83
+ */
84
+ export declare function ensureKey(opts: EnsureKeyOptions): Promise<{
85
+ key?: string;
86
+ signedIn: boolean;
87
+ }>;
88
+ /**
89
+ * What the scaffold will actually push: strings, namespaces, files.
90
+ *
91
+ * `init` otherwise ends on a promise ("wrote lionrapid.yml") that the user can
92
+ * only check by running `push`. Parsing what it just declared turns the last
93
+ * line into a fact. A source that will not parse contributes nothing rather
94
+ * than failing the run — `doctor` and `push` are where that becomes an error.
95
+ */
96
+ export declare function summarizeSources(cwd: string, files: DetectedFile[]): {
97
+ strings: number;
98
+ namespaces: number;
99
+ files: number;
100
+ };
42
101
  interface DetectedFile {
43
102
  format: FileFormat;
44
103
  source: string;
@@ -52,7 +111,8 @@ export interface DetectResult {
52
111
  /** Inspect the project for known i18n stacks and propose file mappings. */
53
112
  export declare function detectProject(cwd: string): DetectResult;
54
113
  export interface InitAnswers {
55
- project?: string;
114
+ /** The project uuid the key resolved to, when the server disclosed one. */
115
+ projectId?: string;
56
116
  server?: string;
57
117
  sourceLocale?: string;
58
118
  targetLocales?: string[];
@@ -74,6 +134,17 @@ export interface InitResult {
74
134
  applied: InitChange[];
75
135
  /** Differences left alone: your value stands until you edit or use --force. */
76
136
  conflicts: InitChange[];
137
+ /**
138
+ * Declared `files[].source` paths with nothing behind them on disk.
139
+ *
140
+ * Reconcile matches entries by format+source, so a stale entry is never
141
+ * touched — a newly detected stanza is appended BESIDE it and reported as a
142
+ * cheerful `+ files[…]`. The user then meets `doctor: sources: 1 missing`
143
+ * with two entries and no idea which is wrong, because the one command that
144
+ * saw both said nothing. A glob counts as missing only when it matches no
145
+ * file: the pattern itself is not a path.
146
+ */
147
+ missingSources: string[];
77
148
  }
78
149
  export declare function runInit(cwd: string, answers?: InitAnswers): InitResult;
79
150
  export {};
Binary file
@@ -10,10 +10,25 @@
10
10
  * `default`. The key itself is never echoed or included in any envelope.
11
11
  */
12
12
  import { type FetchImpl } from '../api-client.js';
13
- export declare const DEFAULT_SERVER = "https://api.lionrapid.com";
13
+ /**
14
+ * The server a bare `login`/`init` targets. THE only copy of this host — a
15
+ * second literal in `init.ts` is how it silently drifted before.
16
+ *
17
+ * `api.lionrapid.com` (the previous value) has no DNS record, so every fresh
18
+ * `init` wrote an unreachable server; credentials are then saved per server
19
+ * URL, the next lookup misses, and the CLI blames a missing API key.
20
+ */
21
+ export declare const DEFAULT_SERVER = "https://app.lionrapid.com";
14
22
  export interface LoginOptions {
15
23
  /** Save the key scoped to this server URL (omitted: save as `default`). */
16
24
  server?: string;
25
+ /**
26
+ * Override which entry to write. `init` needs this: it has already resolved
27
+ * a server to VERIFY against (possibly from the repo's yml or the single
28
+ * saved credential), but should still write `default` unless the user
29
+ * actually passed `--server`. Defaults to login's own rule.
30
+ */
31
+ scope?: 'default' | 'server';
17
32
  /** The API key (omitted: hidden interactive prompt). */
18
33
  key?: string;
19
34
  /** Credentials file path (defaults to ~/.lionrapid/credentials). */
@@ -31,17 +46,48 @@ export interface LoginResult {
31
46
  credentialsPath: string;
32
47
  }
33
48
  export interface LogoutOptions {
49
+ /** Remove exactly this scope. */
34
50
  server?: string;
51
+ /** Remove every scope and delete the file. */
52
+ all?: boolean;
35
53
  credentialsPath?: string;
54
+ /**
55
+ * Ask which scope to remove when several are saved. Returning `undefined`
56
+ * cancels. Omitted = nothing can ask (CI, pipes), which REFUSES rather than
57
+ * guessing — see {@link runLogout}.
58
+ */
59
+ choose?: (scopes: string[]) => Promise<string | undefined> | string | undefined;
36
60
  }
37
61
  export interface LogoutResult {
38
- scope: 'default' | 'server';
39
- /** True when an entry was actually deleted. */
62
+ /** The entry removed: a server URL, `default`, or `all`. */
63
+ scope: string;
64
+ /** True when something was actually deleted. */
40
65
  removed: boolean;
66
+ /** How many entries went (1, or the whole file under `--all`). */
67
+ removedScopes: number;
41
68
  credentialsPath: string;
42
69
  }
43
70
  export declare function runLogin(opts?: LoginOptions): Promise<LoginResult>;
44
- export declare function runLogout(opts?: LogoutOptions): LogoutResult;
71
+ /**
72
+ * Remove a saved credential.
73
+ *
74
+ * Bare `logout` used to remove ONLY `default` — surprising when you hold three
75
+ * scopes and expected to be signed out, and a silent no-op when you hold one
76
+ * scoped key and no default at all. The shape now (owner's decision,
77
+ * 2026-08-07):
78
+ *
79
+ * logout one scope saved → remove it
80
+ * several saved → ask which
81
+ * non-interactive → refuse, naming --server / --all
82
+ * logout --server <url> remove that scope
83
+ * logout --all remove everything, delete the file
84
+ *
85
+ * No silent partial sign-out, and no destructive default. The picker appears
86
+ * only where the answer is genuinely ambiguous, so the common one-scope case
87
+ * stays a single keystroke. Deleting a credential is not something to guess
88
+ * at: with several saved and nothing able to ask, it refuses.
89
+ */
90
+ export declare function runLogout(opts?: LogoutOptions): Promise<LogoutResult>;
45
91
  /** Hidden interactive prompt (no echo) via node:readline — no new dependency. */
46
92
  export declare function promptHidden(question?: string): Promise<string>;
47
93
  //# sourceMappingURL=login.d.ts.map
@@ -14,9 +14,17 @@ import { dirname } from 'node:path';
14
14
  import { createInterface } from 'node:readline';
15
15
  import { ApiKeyError, assertKeyCharset, defaultCredentialsPath, findConfigPath, loadConfig, } from '../config.js';
16
16
  import { LionRapidApiClient } from '../api-client.js';
17
- export const DEFAULT_SERVER = 'https://api.lionrapid.com';
17
+ /**
18
+ * The server a bare `login`/`init` targets. THE only copy of this host — a
19
+ * second literal in `init.ts` is how it silently drifted before.
20
+ *
21
+ * `api.lionrapid.com` (the previous value) has no DNS record, so every fresh
22
+ * `init` wrote an unreachable server; credentials are then saved per server
23
+ * URL, the next lookup misses, and the CLI blames a missing API key.
24
+ */
25
+ export const DEFAULT_SERVER = 'https://app.lionrapid.com';
18
26
  export async function runLogin(opts = {}) {
19
- const scope = opts.server ? 'server' : 'default';
27
+ const scope = opts.scope ?? (opts.server ? 'server' : 'default');
20
28
  const server = normalizeServer(opts.server ?? repoServer(opts.cwd) ?? DEFAULT_SERVER);
21
29
  const key = (opts.key ?? (await (opts.promptKey ?? promptHidden)())).trim();
22
30
  if (!key)
@@ -38,24 +46,70 @@ export async function runLogin(opts = {}) {
38
46
  writeCreds(path, creds);
39
47
  return { server, scope, credentialsPath: path };
40
48
  }
41
- export function runLogout(opts = {}) {
42
- const scope = opts.server ? 'server' : 'default';
43
- const entry = opts.server ? normalizeServer(opts.server) : 'default';
49
+ /**
50
+ * Remove a saved credential.
51
+ *
52
+ * Bare `logout` used to remove ONLY `default` — surprising when you hold three
53
+ * scopes and expected to be signed out, and a silent no-op when you hold one
54
+ * scoped key and no default at all. The shape now (owner's decision,
55
+ * 2026-08-07):
56
+ *
57
+ * logout one scope saved → remove it
58
+ * several saved → ask which
59
+ * non-interactive → refuse, naming --server / --all
60
+ * logout --server <url> remove that scope
61
+ * logout --all remove everything, delete the file
62
+ *
63
+ * No silent partial sign-out, and no destructive default. The picker appears
64
+ * only where the answer is genuinely ambiguous, so the common one-scope case
65
+ * stays a single keystroke. Deleting a credential is not something to guess
66
+ * at: with several saved and nothing able to ask, it refuses.
67
+ */
68
+ export async function runLogout(opts = {}) {
44
69
  const path = opts.credentialsPath ?? defaultCredentialsPath();
70
+ const nothing = (scope) => ({
71
+ scope,
72
+ removed: false,
73
+ removedScopes: 0,
74
+ credentialsPath: path,
75
+ });
45
76
  if (!existsSync(path))
46
- return { scope, removed: false, credentialsPath: path };
77
+ return nothing(opts.all ? 'all' : (opts.server ?? 'default'));
47
78
  const creds = readCreds(path);
48
- const removed = Object.prototype.hasOwnProperty.call(creds, entry);
49
- if (removed) {
50
- delete creds[entry];
51
- if (Object.keys(creds).length === 0) {
52
- rmSync(path);
53
- }
54
- else {
55
- writeCreds(path, creds);
56
- }
79
+ const scopes = Object.keys(creds);
80
+ if (opts.all) {
81
+ if (scopes.length === 0)
82
+ return nothing('all');
83
+ rmSync(path);
84
+ return { scope: 'all', removed: true, removedScopes: scopes.length, credentialsPath: path };
85
+ }
86
+ const entry = opts.server
87
+ ? normalizeServer(opts.server)
88
+ : scopes.length === 1
89
+ ? scopes[0]
90
+ : scopes.length === 0
91
+ ? undefined
92
+ : await pick(scopes, opts.choose, path);
93
+ if (entry === undefined)
94
+ return nothing(opts.server ?? 'default');
95
+ if (!Object.prototype.hasOwnProperty.call(creds, entry))
96
+ return nothing(entry);
97
+ delete creds[entry];
98
+ if (Object.keys(creds).length === 0)
99
+ rmSync(path);
100
+ else
101
+ writeCreds(path, creds);
102
+ return { scope: entry, removed: true, removedScopes: 1, credentialsPath: path };
103
+ }
104
+ /** Ask which scope to drop, or refuse when nothing can ask. */
105
+ async function pick(scopes, choose, path) {
106
+ if (!choose) {
107
+ throw new ApiKeyError(`${scopes.length} saved credentials in ${path} — which one?\n` +
108
+ `${scopes.map((s) => ` · ${s}`).join('\n')}\n` +
109
+ ' Name one with `lionrapid logout --server <url>`, or remove them all with\n' +
110
+ ' `lionrapid logout --all`.');
57
111
  }
58
- return { scope, removed, credentialsPath: path };
112
+ return choose(scopes);
59
113
  }
60
114
  /** Match the `config.server` normalization so scoped lookups hit exactly. */
61
115
  function normalizeServer(server) {
@@ -26,6 +26,11 @@ export interface PullFileResult {
26
26
  added?: number;
27
27
  updated?: number;
28
28
  keptLocal?: number;
29
+ /**
30
+ * Keys kept because the local file was edited since the last pull — the
31
+ * server value was NOT written over them. Absent is the normal case.
32
+ */
33
+ conflicts?: string[];
29
34
  /** Set when this file failed (fetch or write) — the file was not written. */
30
35
  error?: string;
31
36
  }
@@ -40,6 +45,14 @@ export interface PullResult {
40
45
  /** True when this was a dry run — no files were written. */
41
46
  dryRun?: boolean;
42
47
  }
48
+ export interface MergeOptions {
49
+ /**
50
+ * Bare key → the hash the server last gave us for this locale, from the
51
+ * lock. Absent (no lock, or a key the lock has never seen) means today's
52
+ * behaviour: server wins. A first pull must not look like 653 conflicts.
53
+ */
54
+ lockTargets?: Record<string, string>;
55
+ }
43
56
  /**
44
57
  * Overlay fetched entries onto the existing local file: server wins per key,
45
58
  * local-only entries survive, existing key order is preserved and new keys
@@ -47,11 +60,12 @@ export interface PullResult {
47
60
  * has no comment channel, so replacing the entry would silently drop them.
48
61
  * Exported for direct unit testing.
49
62
  */
50
- export declare function mergeEntries(local: TranslationEntry[], fetched: TranslationEntry[]): {
63
+ export declare function mergeEntries(local: TranslationEntry[], fetched: TranslationEntry[], opts?: MergeOptions): {
51
64
  entries: TranslationEntry[];
52
65
  added: number;
53
66
  updated: number;
54
67
  keptLocal: number;
68
+ conflicts: string[];
55
69
  };
56
70
  export declare function runPull(config: LionRapidConfig, client: LionRapidApiClient, opts: PullOptions): Promise<PullResult>;
57
71
  //# sourceMappingURL=pull.d.ts.map
@@ -10,7 +10,42 @@
10
10
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
11
  import { dirname, resolve } from 'node:path';
12
12
  import { getAdapter } from '../adapters/index.js';
13
+ import { saveHistory } from '../history.js';
14
+ import { hashSource, lockKey, readLock, writeLock } from '../lockfile.js';
13
15
  import { assertInside, mapLimit, resolveConcurrency } from './context.js';
16
+ /** The lock's last-known server hashes for one namespace+locale, by bare key. */
17
+ function lockTargetsFor(lock, namespace, locale) {
18
+ const out = {};
19
+ const prefix = `${namespace}:`;
20
+ for (const [key, entry] of Object.entries(lock.entries)) {
21
+ if (!key.startsWith(prefix))
22
+ continue;
23
+ const hash = entry.targets?.[locale]?.hash;
24
+ if (hash)
25
+ out[key.slice(prefix.length)] = hash;
26
+ }
27
+ return out;
28
+ }
29
+ /**
30
+ * Fold a namespace's fetched rows into the lock, for one target locale.
31
+ *
32
+ * `sourceHash` is deliberately left alone: pull sees TARGET rows, and claiming
33
+ * to know the server's source state from them would let the next push skip a
34
+ * source string it never actually confirmed.
35
+ */
36
+ function recordServerState(lock, namespace, locale, fetched, pulledAt) {
37
+ for (const entry of fetched) {
38
+ const key = lockKey(namespace, entry.key);
39
+ const existing = lock.entries[key] ?? { sourceHash: '' };
40
+ lock.entries[key] = {
41
+ ...existing,
42
+ targets: {
43
+ ...existing.targets,
44
+ [locale]: { hash: hashSource(entry.value), serverUpdatedAt: pulledAt },
45
+ },
46
+ };
47
+ }
48
+ }
14
49
  /**
15
50
  * Overlay fetched entries onto the existing local file: server wins per key,
16
51
  * local-only entries survive, existing key order is preserved and new keys
@@ -18,9 +53,10 @@ import { assertInside, mapLimit, resolveConcurrency } from './context.js';
18
53
  * has no comment channel, so replacing the entry would silently drop them.
19
54
  * Exported for direct unit testing.
20
55
  */
21
- export function mergeEntries(local, fetched) {
56
+ export function mergeEntries(local, fetched, opts = {}) {
22
57
  const newByKey = new Map(fetched.map((e) => [e.key, e]));
23
58
  const entries = [];
59
+ const conflicts = [];
24
60
  let updated = 0;
25
61
  let keptLocal = 0;
26
62
  for (const l of local) {
@@ -30,6 +66,21 @@ export function mergeEntries(local, fetched) {
30
66
  entries.push(l);
31
67
  continue;
32
68
  }
69
+ // THE MISSING DIMENSION. Presence alone cannot tell a local EDIT from a
70
+ // local-only key; the lock's last-known server hash can. A local value
71
+ // that no longer matches what the server last gave us was edited here
72
+ // since the last pull, so taking the server's value would destroy it.
73
+ //
74
+ // The asymmetry is deliberate: losing a translator's work is worse than a
75
+ // stale local file, so the destructive direction needs `--force`.
76
+ const lastKnown = opts.lockTargets?.[l.key];
77
+ if (lastKnown !== undefined && hashSource(l.value) !== lastKnown) {
78
+ conflicts.push(l.key);
79
+ keptLocal += 1;
80
+ entries.push(l);
81
+ newByKey.delete(l.key);
82
+ continue;
83
+ }
33
84
  if (JSON.stringify(f.value) !== JSON.stringify(l.value))
34
85
  updated += 1;
35
86
  entries.push(l.comment !== undefined ? { ...f, comment: l.comment } : f);
@@ -42,7 +93,7 @@ export function mergeEntries(local, fetched) {
42
93
  added += 1;
43
94
  }
44
95
  }
45
- return { entries, added, updated, keptLocal };
96
+ return { entries, added, updated, keptLocal, conflicts };
46
97
  }
47
98
  /** Union of source keys per namespace, for "pending" reporting. */
48
99
  function sourceKeysByNamespace(config, cwd) {
@@ -113,6 +164,12 @@ export async function runPull(config, client, opts) {
113
164
  : null;
114
165
  catalogContent.set(file, base);
115
166
  }
167
+ // The lockfile records what the server last said, so the NEXT push can send
168
+ // only what changed and the next pull can tell a local edit from a
169
+ // local-only key. Merged into whatever is already there: another locale or
170
+ // namespace fetched on an earlier run must not be erased by this one.
171
+ const lock = readLock(opts.cwd);
172
+ const pulledAt = new Date().toISOString();
116
173
  const locales = [];
117
174
  for (const locale of config.targetLocales) {
118
175
  const fileResults = [];
@@ -139,10 +196,28 @@ export async function runPull(config, client, opts) {
139
196
  // wholesale overwrite.
140
197
  let toWrite = fetched;
141
198
  let diff;
199
+ let conflicts;
200
+ // `--force` is about to discard whatever is in this file. Keep a copy
201
+ // first — symmetric with `push --force`, and the only thing standing
202
+ // between the flag and a translator's lost work.
203
+ if (opts.force && !opts.dryRun && existsSync(absPath)) {
204
+ saveHistory(opts.cwd, {
205
+ direction: 'pull',
206
+ namespace,
207
+ locale,
208
+ reason: `pull --force replaced ${relPath} with the server's version`,
209
+ entries: adapter.parse(readFileSync(absPath, 'utf8')),
210
+ });
211
+ }
142
212
  if (!opts.force && fetched.length > 0 && existsSync(absPath)) {
143
- const merged = mergeEntries(adapter.parse(readFileSync(absPath, 'utf8')), fetched);
213
+ const merged = mergeEntries(adapter.parse(readFileSync(absPath, 'utf8')), fetched, {
214
+ lockTargets: lockTargetsFor(lock, namespace, locale),
215
+ });
144
216
  toWrite = merged.entries;
145
217
  diff = { added: merged.added, updated: merged.updated, keptLocal: merged.keptLocal };
218
+ if (merged.conflicts.length > 0) {
219
+ conflicts = merged.conflicts.map((key) => lockKey(namespace, key));
220
+ }
146
221
  }
147
222
  // Don't clobber an existing target with an empty result (locked/pending).
148
223
  // Dry run computes counts but writes nothing.
@@ -154,7 +229,16 @@ export async function runPull(config, client, opts) {
154
229
  flat: file.flat,
155
230
  }));
156
231
  }
157
- fileResults.push({ target: relPath, count: fetched.length, ...(diff ?? {}) });
232
+ // Record what the SERVER just said. `pull` is the only writer of
233
+ // `serverUpdatedAt` — push must never fabricate a server state it did
234
+ // not observe.
235
+ recordServerState(lock, namespace, locale, fetched, pulledAt);
236
+ fileResults.push({
237
+ target: relPath,
238
+ count: fetched.length,
239
+ ...(diff ?? {}),
240
+ ...(conflicts ? { conflicts } : {}),
241
+ });
158
242
  }
159
243
  catch (err) {
160
244
  firstError ??= err;
@@ -237,6 +321,10 @@ export async function runPull(config, client, opts) {
237
321
  if (all.length > 0 && all.every((f) => f.error) && firstError !== undefined) {
238
322
  throw firstError;
239
323
  }
324
+ // A dry run writes no lock for the same reason it writes no files: it did
325
+ // not change anything, so nothing new is known.
326
+ if (!opts.dryRun)
327
+ writeLock(opts.cwd, lock);
240
328
  return { locales, ...(opts.dryRun ? { dryRun: true } : {}) };
241
329
  }
242
330
  //# sourceMappingURL=pull.js.map
@@ -14,7 +14,7 @@
14
14
  * via `targets/batch`, optionally publishing them with `--publish`.
15
15
  */
16
16
  import { type LionRapidConfig } from '../config.js';
17
- import type { LionRapidApiClient } from '../api-client.js';
17
+ import type { LionRapidApiClient, TargetRowOutcome } from '../api-client.js';
18
18
  /**
19
19
  * A sync job the server queued for one batch of this push.
20
20
  *
@@ -46,11 +46,37 @@ export interface PushOptions {
46
46
  * before a later total failure are not lost when `runPush` rethrows.
47
47
  */
48
48
  onJob?: (job: PushedJob) => void;
49
+ /**
50
+ * Project root for `.lionrapid/lock.json`. Omitted = no lock at all: every
51
+ * key is sent, exactly as before L2. Tests use it to point at a scratch dir;
52
+ * the CLI passes the project root.
53
+ */
54
+ lockPath?: string;
55
+ /**
56
+ * Send what the SERVER lacks and nothing else, asking the server rather than
57
+ * trusting the lock. This is the recovery for a job that was accepted (202)
58
+ * and then dead-lettered: the lock records those rows as sent, so only the
59
+ * server can say they are missing.
60
+ */
61
+ backfillMissing?: boolean;
62
+ /**
63
+ * Send everything, overwriting server values — saving the server's current
64
+ * source rows to `.lionrapid/history/` first, the way `pull --force` saves
65
+ * the local ones.
66
+ */
67
+ force?: boolean;
49
68
  }
50
69
  export interface PushFileResult {
51
70
  file: string;
52
71
  count: number;
53
72
  skipped?: boolean;
73
+ /** Keys this file already had on the server, per the lock — not re-sent. */
74
+ unchanged?: number;
75
+ /**
76
+ * Rows sent for this file that the server did not write — protected,
77
+ * denied, or skipped. Target mode only; `count` is what actually landed.
78
+ */
79
+ refused?: number;
54
80
  /** Set when this file failed (parse or upload) — it was not (fully) pushed. */
55
81
  error?: string;
56
82
  }
@@ -61,6 +87,35 @@ export interface PushResult {
61
87
  * `jobs` is what says whether they landed.
62
88
  */
63
89
  pushed: number;
90
+ /** Keys the lock says the server already has, so they were not sent again. */
91
+ skipped: number;
92
+ /**
93
+ * Rows the server REFUSED to overwrite because a human committed them.
94
+ *
95
+ * A machine caller (no user identity) does not get to overwrite a
96
+ * translator's committed work, and there is deliberately no override. These
97
+ * were NOT written, so they are not in `pushed`.
98
+ */
99
+ protected: TargetRowOutcome[];
100
+ /** Rows the caller was not authorized to edit. Same class: not written. */
101
+ denied: TargetRowOutcome[];
102
+ /**
103
+ * Rows the SERVER skipped — source row missing, or the value would not
104
+ * parse. Distinct from `protected`: a different cause, and both can be
105
+ * non-zero in one push. Named to avoid colliding with `skipped` above,
106
+ * which is this CLI's lockfile diff.
107
+ */
108
+ serverSkipped: number;
109
+ /**
110
+ * What this push is doing, split so `--dry-run` can answer the question you
111
+ * ran it to ask: am I ADDING strings, or overwriting ones that already
112
+ * exist? "2 to send" hides which two. Source mode only.
113
+ */
114
+ diff?: {
115
+ added: number;
116
+ changed: number;
117
+ unchanged: number;
118
+ };
64
119
  files: PushFileResult[];
65
120
  /** Jobs the server queued for this push (source mode only; empty on a dry run). */
66
121
  jobs: PushedJob[];
@@ -68,6 +123,12 @@ export interface PushResult {
68
123
  publishSkipped?: boolean;
69
124
  /** True when this was a dry run — nothing was sent to the server. */
70
125
  dryRun?: boolean;
126
+ /**
127
+ * Whether the config's `projectId` was checked against the key's project.
128
+ * `unavailable` means the server disclosed none — the guard could not arm,
129
+ * which is never a reason to block a push.
130
+ */
131
+ projectCheck?: 'ok' | 'unavailable';
71
132
  }
72
133
  export declare function runPush(config: LionRapidConfig, client: LionRapidApiClient, opts: PushOptions): Promise<PushResult>;
73
134
  //# sourceMappingURL=push.d.ts.map