@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.
@@ -10,11 +10,33 @@
10
10
  */
11
11
  import { existsSync } from 'node:fs';
12
12
  import { resolve } from 'node:path';
13
- import { expandGlobEntries, findConfigPath, loadConfig, resolveApiKey, } from '../config.js';
13
+ import { expandGlobEntries, findConfigPath, loadConfig, resolveApiKey, keyCapability, listCredentialServers, ConfigError, } from '../config.js';
14
14
  import { LionRapidApiClient } from '../api-client.js';
15
+ /**
16
+ * First line only. Right for a network error (whose `cause` chain is long and
17
+ * repetitive), wrong for a ConfigError — see {@link configDetail}.
18
+ */
15
19
  function msg(err) {
16
20
  return err instanceof Error ? err.message.split('\n')[0] : String(err);
17
21
  }
22
+ /**
23
+ * A config failure, with its issues.
24
+ *
25
+ * `msg()` kept the first line only, so `Invalid lionrapid config:` printed
26
+ * with every bullet the validator had produced discarded — a header promising
27
+ * a list, followed by nothing. Other commands print `err.message` whole and
28
+ * showed the bullets, which is what made the renderer look inconsistent: it is
29
+ * not per-field, it is per-command. ConfigError carries the issues as typed
30
+ * data, so use that rather than re-splitting a string.
31
+ */
32
+ function configDetail(err) {
33
+ if (err instanceof ConfigError && err.issues.length > 0) {
34
+ return `${err.message.split('\n')[0]} ${err.issues
35
+ .map((i) => `${i.path}: ${i.message}`)
36
+ .join('; ')}`;
37
+ }
38
+ return msg(err);
39
+ }
18
40
  export async function runDoctor(opts) {
19
41
  const checks = [];
20
42
  // 1. Config present + valid.
@@ -39,16 +61,36 @@ export async function runDoctor(opts) {
39
61
  }
40
62
  }
41
63
  catch (err) {
42
- checks.push({ name: 'config', ok: false, detail: msg(err) });
64
+ checks.push({ name: 'config', ok: false, detail: configDetail(err) });
43
65
  }
44
66
  // 2. API key resolvable (never print the key itself).
67
+ //
68
+ // Keys are stored PER SERVER URL, so without a valid config there is no
69
+ // server to look one up by — the lookup cannot run, it does not fail. Saying
70
+ // "No API key found" here asserted something false about a key that was
71
+ // present and correct, and sent the owner hunting through a credentials file
72
+ // that had nothing wrong with it. Report the truth: skipped, and what IS
73
+ // saved, so they can see for themselves.
45
74
  let apiKey;
75
+ const savedScopes = listCredentialServers(opts.credentialsPath);
46
76
  try {
47
- apiKey = resolveApiKey({ server: config?.server });
48
- checks.push({ name: 'apiKey', ok: true, detail: 'resolved from env/credentials' });
77
+ apiKey = resolveApiKey({ server: config?.server, credentialsPath: opts.credentialsPath });
78
+ // Name what the key can DO, not just that it exists. A pk_ holder
79
+ // otherwise discovers the targets:write line mid-publish, as a bare 403.
80
+ checks.push({
81
+ name: 'apiKey',
82
+ ok: true,
83
+ detail: `resolved from env/credentials · ${keyCapability(apiKey).summary}`,
84
+ });
49
85
  }
50
86
  catch (err) {
51
- checks.push({ name: 'apiKey', ok: false, detail: msg(err) });
87
+ checks.push({
88
+ name: 'apiKey',
89
+ ok: false,
90
+ detail: config
91
+ ? msg(err)
92
+ : `skipped — needs a valid config (keys are saved per server URL)${savedScopes.length > 0 ? `; saved: ${savedScopes.join(', ')}` : ''}`,
93
+ });
52
94
  }
53
95
  // 3. Server reachable + token accepted (no retries — doctor wants fast feedback).
54
96
  if (config && apiKey) {
@@ -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
@@ -5,11 +5,34 @@
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
  */
11
16
  import { type LionRapidConfig } from '../config.js';
12
17
  import type { LionRapidApiClient } from '../api-client.js';
18
+ /**
19
+ * A sync job the server queued for one batch of this push.
20
+ *
21
+ * `sync/batch` answers 202 — accepted, not stored. This is the receipt that
22
+ * makes the difference observable: it says which file's keys are riding on
23
+ * which job, so `lionrapid status <id>` can later answer "stored" or "dropped".
24
+ * Target mode has no equivalent because `targets/batch` writes synchronously.
25
+ */
26
+ export interface PushedJob {
27
+ id: string;
28
+ /** The file whose keys were in this batch. */
29
+ file: string;
30
+ namespace: string;
31
+ /** Keys in this batch (a large file is chunked into several jobs). */
32
+ keys: number;
33
+ /** Status the server reported at enqueue time (`pending`). */
34
+ status: string;
35
+ }
13
36
  export interface PushOptions {
14
37
  cwd: string;
15
38
  /** Push an already-translated target locale instead of the source. */
@@ -18,22 +41,72 @@ export interface PushOptions {
18
41
  publish?: boolean;
19
42
  /** Parse + count what would be pushed, but make no network calls. */
20
43
  dryRun?: boolean;
44
+ /**
45
+ * Called as each batch is accepted. The caller persists these, so ids queued
46
+ * before a later total failure are not lost when `runPush` rethrows.
47
+ */
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;
21
68
  }
22
69
  export interface PushFileResult {
23
70
  file: string;
24
71
  count: number;
25
72
  skipped?: boolean;
73
+ /** Keys this file already had on the server, per the lock — not re-sent. */
74
+ unchanged?: number;
26
75
  /** Set when this file failed (parse or upload) — it was not (fully) pushed. */
27
76
  error?: string;
28
77
  }
29
78
  export interface PushResult {
30
79
  mode: 'source' | 'target';
80
+ /**
81
+ * Keys the CLI SENT. Not the number stored — the server queues them, so
82
+ * `jobs` is what says whether they landed.
83
+ */
31
84
  pushed: number;
85
+ /** Keys the lock says the server already has, so they were not sent again. */
86
+ skipped: number;
87
+ /**
88
+ * What this push is doing, split so `--dry-run` can answer the question you
89
+ * ran it to ask: am I ADDING strings, or overwriting ones that already
90
+ * exist? "2 to send" hides which two. Source mode only.
91
+ */
92
+ diff?: {
93
+ added: number;
94
+ changed: number;
95
+ unchanged: number;
96
+ };
32
97
  files: PushFileResult[];
98
+ /** Jobs the server queued for this push (source mode only; empty on a dry run). */
99
+ jobs: PushedJob[];
33
100
  /** True when `--publish` was requested but no editor token was available. */
34
101
  publishSkipped?: boolean;
35
102
  /** True when this was a dry run — nothing was sent to the server. */
36
103
  dryRun?: boolean;
104
+ /**
105
+ * Whether the config's `projectId` was checked against the key's project.
106
+ * `unavailable` means the server disclosed none — the guard could not arm,
107
+ * which is never a reason to block a push.
108
+ */
109
+ projectCheck?: 'ok' | 'unavailable';
37
110
  }
38
111
  export declare function runPush(config: LionRapidConfig, client: LionRapidApiClient, opts: PushOptions): Promise<PushResult>;
39
112
  //# sourceMappingURL=push.d.ts.map