@lionrapid/cli 0.2.1 → 0.3.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.
@@ -5,7 +5,8 @@
5
5
  * turned into a failed check.
6
6
  *
7
7
  * Checks: config found + valid → API key resolvable → server reachable + the
8
- * token works (ping) → declared source files present.
8
+ * token works (config:read probe — pk_-compatible) → declared source files
9
+ * present.
9
10
  */
10
11
  import { type FetchImpl } from '../api-client.js';
11
12
  export interface DoctorCheck {
@@ -5,11 +5,12 @@
5
5
  * turned into a failed check.
6
6
  *
7
7
  * Checks: config found + valid → API key resolvable → server reachable + the
8
- * token works (ping) → declared source files present.
8
+ * token works (config:read probe — pk_-compatible) → declared source files
9
+ * present.
9
10
  */
10
11
  import { existsSync } from 'node:fs';
11
12
  import { resolve } from 'node:path';
12
- import { findConfigPath, loadConfig, resolveApiKey, } from '../config.js';
13
+ import { expandGlobEntries, findConfigPath, loadConfig, resolveApiKey, } from '../config.js';
13
14
  import { LionRapidApiClient } from '../api-client.js';
14
15
  function msg(err) {
15
16
  return err instanceof Error ? err.message.split('\n')[0] : String(err);
@@ -29,6 +30,7 @@ export async function runDoctor(opts) {
29
30
  }
30
31
  else {
31
32
  config = loadConfig(path);
33
+ config = { ...config, files: expandGlobEntries(config.files, opts.cwd) };
32
34
  checks.push({
33
35
  name: 'config',
34
36
  ok: true,
@@ -57,11 +59,13 @@ export async function runDoctor(opts) {
57
59
  retries: 0,
58
60
  fetchImpl: opts.fetchImpl,
59
61
  });
60
- const res = await client.ping();
62
+ // config:read probe — works for every key class incl. publishable pk_
63
+ // (editor/workflow needs targets:write and would fail pk_ keys).
64
+ const pc = await client.getProjectConfig();
61
65
  checks.push({
62
66
  name: 'server',
63
67
  ok: true,
64
- detail: `${config.server} reachable · ${res.isPmAdmin ? 'pm-admin' : 'integration'} token`,
68
+ detail: `${config.server} reachable · project locales: ${pc.defaultLocale || '?'} → ${pc.languages.filter((l) => l !== pc.defaultLocale).join(', ') || '?'}`,
65
69
  });
66
70
  }
67
71
  catch (err) {
@@ -8,10 +8,42 @@
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';
12
+ /**
13
+ * Harvest the target locales a repo *already has* by matching the detected
14
+ * `target` patterns against real files — so `init` can scaffold what's on
15
+ * disk instead of a hardcoded guess when the server has nothing to say.
16
+ * Multi-locale catalogs (no `{locale}` in the path) can't be detected this
17
+ * way and are skipped. Returns sorted, deduped locales minus `sourceLocale`.
18
+ */
19
+ export declare function detectLocalesOnDisk(cwd: string, sourceLocale: string): string[];
20
+ /**
21
+ * What the project-config lookup produced. `no-targets` is deliberately
22
+ * distinct from `unavailable`: a brand-new project answers perfectly well and
23
+ * simply has no target languages yet — reporting that as "couldn't read your
24
+ * locales" hides the one thing the user has to go do.
25
+ */
26
+ export type LocaleDefaults = {
27
+ status: 'ok';
28
+ sourceLocale: string;
29
+ targetLocales: string[];
30
+ } | {
31
+ status: 'no-targets';
32
+ sourceLocale: string;
33
+ } | {
34
+ status: 'unavailable';
35
+ };
36
+ /**
37
+ * Fetch the project's configured locales for scaffold prefill. Never throws —
38
+ * offline, an older server, or a bad key all degrade to `unavailable`, so
39
+ * `init` keeps working fully offline.
40
+ */
41
+ export declare function fetchLocaleDefaults(client: Pick<LionRapidApiClient, 'getProjectConfig'>): Promise<LocaleDefaults>;
11
42
  interface DetectedFile {
12
43
  format: FileFormat;
13
44
  source: string;
14
45
  target: string;
46
+ namespace?: string;
15
47
  }
16
48
  export interface DetectResult {
17
49
  stacks: string[];
@@ -26,10 +58,22 @@ export interface InitAnswers {
26
58
  targetLocales?: string[];
27
59
  force?: boolean;
28
60
  }
61
+ /** One field `init` changed, or wanted to change but didn't. */
62
+ export interface InitChange {
63
+ path: string;
64
+ from?: string;
65
+ to: string;
66
+ }
29
67
  export interface InitResult {
30
68
  configPath: string;
31
69
  yaml: string;
32
70
  detected: DetectResult;
71
+ /** True when there was no config before this run. */
72
+ created: boolean;
73
+ /** Changes written (new fields/entries — nothing was overwritten). */
74
+ applied: InitChange[];
75
+ /** Differences left alone: your value stands until you edit or use --force. */
76
+ conflicts: InitChange[];
33
77
  }
34
78
  export declare function runInit(cwd: string, answers?: InitAnswers): InitResult;
35
79
  export {};
Binary file
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `login` / `logout` — manage `~/.lionrapid/credentials`, the JSON map
3
+ * (`{ "default": "sk_…", "<server-url>": "sk_…" }`) that `resolveApiKey`
4
+ * reads. The key is verified against the server BEFORE persisting (a
5
+ * config:read probe, so publishable pk_ keys work too) — a failed
6
+ * verification writes nothing. Directory is 0700, file 0600.
7
+ *
8
+ * `--server` writes a server-scoped entry (the per-host opt-in that
9
+ * `resolveApiKey` demands for non-LionRapid hosts); bare `login` writes
10
+ * `default`. The key itself is never echoed or included in any envelope.
11
+ */
12
+ import { type FetchImpl } from '../api-client.js';
13
+ export declare const DEFAULT_SERVER = "https://api.lionrapid.com";
14
+ export interface LoginOptions {
15
+ /** Save the key scoped to this server URL (omitted: save as `default`). */
16
+ server?: string;
17
+ /** The API key (omitted: hidden interactive prompt). */
18
+ key?: string;
19
+ /** Credentials file path (defaults to ~/.lionrapid/credentials). */
20
+ credentialsPath?: string;
21
+ /** Directory to look for a repo lionrapid.yml whose server becomes the ping target. */
22
+ cwd?: string;
23
+ /** Injectable fetch for the verification ping. */
24
+ fetchImpl?: FetchImpl;
25
+ /** Injectable prompt (defaults to a muted readline prompt on stderr). */
26
+ promptKey?: () => Promise<string>;
27
+ }
28
+ export interface LoginResult {
29
+ server: string;
30
+ scope: 'default' | 'server';
31
+ credentialsPath: string;
32
+ }
33
+ export interface LogoutOptions {
34
+ server?: string;
35
+ credentialsPath?: string;
36
+ }
37
+ export interface LogoutResult {
38
+ scope: 'default' | 'server';
39
+ /** True when an entry was actually deleted. */
40
+ removed: boolean;
41
+ credentialsPath: string;
42
+ }
43
+ export declare function runLogin(opts?: LoginOptions): Promise<LoginResult>;
44
+ export declare function runLogout(opts?: LogoutOptions): LogoutResult;
45
+ /** Hidden interactive prompt (no echo) via node:readline — no new dependency. */
46
+ export declare function promptHidden(question?: string): Promise<string>;
47
+ //# sourceMappingURL=login.d.ts.map
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `login` / `logout` — manage `~/.lionrapid/credentials`, the JSON map
3
+ * (`{ "default": "sk_…", "<server-url>": "sk_…" }`) that `resolveApiKey`
4
+ * reads. The key is verified against the server BEFORE persisting (a
5
+ * config:read probe, so publishable pk_ keys work too) — a failed
6
+ * verification writes nothing. Directory is 0700, file 0600.
7
+ *
8
+ * `--server` writes a server-scoped entry (the per-host opt-in that
9
+ * `resolveApiKey` demands for non-LionRapid hosts); bare `login` writes
10
+ * `default`. The key itself is never echoed or included in any envelope.
11
+ */
12
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
13
+ import { dirname } from 'node:path';
14
+ import { createInterface } from 'node:readline';
15
+ import { ApiKeyError, assertKeyCharset, defaultCredentialsPath, findConfigPath, loadConfig, } from '../config.js';
16
+ import { LionRapidApiClient } from '../api-client.js';
17
+ export const DEFAULT_SERVER = 'https://api.lionrapid.com';
18
+ export async function runLogin(opts = {}) {
19
+ const scope = opts.server ? 'server' : 'default';
20
+ const server = normalizeServer(opts.server ?? repoServer(opts.cwd) ?? DEFAULT_SERVER);
21
+ const key = (opts.key ?? (await (opts.promptKey ?? promptHidden)())).trim();
22
+ if (!key)
23
+ throw new ApiKeyError('No API key provided.');
24
+ assertKeyCharset(key);
25
+ // Verify before persisting — a bad key must never land in the file. The
26
+ // probe is project-config (`config:read`), which every key class carries —
27
+ // publishable pk_ keys included; the old editor/workflow ping needed
28
+ // `targets:write` and wrongly rejected pk_ logins.
29
+ const client = new LionRapidApiClient({
30
+ baseUrl: server,
31
+ apiKey: key,
32
+ fetchImpl: opts.fetchImpl,
33
+ });
34
+ await client.getProjectConfig();
35
+ const path = opts.credentialsPath ?? defaultCredentialsPath();
36
+ const creds = readCreds(path);
37
+ creds[scope === 'server' ? server : 'default'] = key;
38
+ writeCreds(path, creds);
39
+ return { server, scope, credentialsPath: path };
40
+ }
41
+ export function runLogout(opts = {}) {
42
+ const scope = opts.server ? 'server' : 'default';
43
+ const entry = opts.server ? normalizeServer(opts.server) : 'default';
44
+ const path = opts.credentialsPath ?? defaultCredentialsPath();
45
+ if (!existsSync(path))
46
+ return { scope, removed: false, credentialsPath: path };
47
+ 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
+ }
57
+ }
58
+ return { scope, removed, credentialsPath: path };
59
+ }
60
+ /** Match the `config.server` normalization so scoped lookups hit exactly. */
61
+ function normalizeServer(server) {
62
+ return server.replace(/\/$/, '');
63
+ }
64
+ /** The repo's configured server, when a valid lionrapid.yml is present. */
65
+ function repoServer(cwd) {
66
+ const path = findConfigPath(cwd);
67
+ if (!path)
68
+ return undefined;
69
+ try {
70
+ return loadConfig(path).server;
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ }
76
+ function readCreds(path) {
77
+ if (!existsSync(path))
78
+ return {};
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
82
+ }
83
+ catch (err) {
84
+ throw new ApiKeyError(`Could not parse credentials file ${path}: ${err instanceof Error ? err.message : String(err)}. Fix or delete it, then retry.`);
85
+ }
86
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
87
+ throw new ApiKeyError(`Credentials file ${path} is not a JSON object. Fix or delete it, then retry.`);
88
+ }
89
+ return parsed;
90
+ }
91
+ function writeCreds(path, creds) {
92
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
93
+ writeFileSync(path, `${JSON.stringify(creds, null, 2)}\n`, { mode: 0o600 });
94
+ // writeFileSync's mode only applies on create — enforce it on merges too.
95
+ chmodSync(path, 0o600);
96
+ }
97
+ /** Hidden interactive prompt (no echo) via node:readline — no new dependency. */
98
+ export function promptHidden(question = 'API key (input hidden): ') {
99
+ return new Promise((resolve) => {
100
+ const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
101
+ process.stderr.write(question);
102
+ rl._writeToOutput = () => { };
103
+ rl.question('', (answer) => {
104
+ rl.close();
105
+ process.stderr.write('\n');
106
+ resolve(answer);
107
+ });
108
+ });
109
+ }
110
+ //# sourceMappingURL=login.js.map
@@ -9,16 +9,25 @@
9
9
  */
10
10
  import type { LionRapidConfig } from '../config.js';
11
11
  import type { LionRapidApiClient } from '../api-client.js';
12
+ import { type TranslationEntry } from '../adapters/index.js';
12
13
  export interface PullOptions {
13
14
  cwd: string;
14
15
  /** Fetch + count what would be written, but write no files. */
15
16
  dryRun?: boolean;
17
+ /** Overwrite target files wholesale instead of merging (drops local-only keys). */
18
+ force?: boolean;
16
19
  /** Progress callback, invoked as each namespace fetch completes. */
17
20
  onProgress?: (done: number, total: number) => void;
18
21
  }
19
22
  export interface PullFileResult {
20
23
  target: string;
21
24
  count: number;
25
+ /** Merge diff vs the existing target (absent on fresh writes and --force). */
26
+ added?: number;
27
+ updated?: number;
28
+ keptLocal?: number;
29
+ /** Set when this file failed (fetch or write) — the file was not written. */
30
+ error?: string;
22
31
  }
23
32
  export interface PullLocaleResult {
24
33
  locale: string;
@@ -31,5 +40,18 @@ export interface PullResult {
31
40
  /** True when this was a dry run — no files were written. */
32
41
  dryRun?: boolean;
33
42
  }
43
+ /**
44
+ * Overlay fetched entries onto the existing local file: server wins per key,
45
+ * local-only entries survive, existing key order is preserved and new keys
46
+ * append. Local `comment`s survive even on overlaid keys — the server bundle
47
+ * has no comment channel, so replacing the entry would silently drop them.
48
+ * Exported for direct unit testing.
49
+ */
50
+ export declare function mergeEntries(local: TranslationEntry[], fetched: TranslationEntry[]): {
51
+ entries: TranslationEntry[];
52
+ added: number;
53
+ updated: number;
54
+ keptLocal: number;
55
+ };
34
56
  export declare function runPull(config: LionRapidConfig, client: LionRapidApiClient, opts: PullOptions): Promise<PullResult>;
35
57
  //# sourceMappingURL=pull.d.ts.map
@@ -10,7 +10,40 @@
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 { mapLimit, resolveConcurrency } from './context.js';
13
+ import { assertInside, mapLimit, resolveConcurrency } from './context.js';
14
+ /**
15
+ * Overlay fetched entries onto the existing local file: server wins per key,
16
+ * local-only entries survive, existing key order is preserved and new keys
17
+ * append. Local `comment`s survive even on overlaid keys — the server bundle
18
+ * has no comment channel, so replacing the entry would silently drop them.
19
+ * Exported for direct unit testing.
20
+ */
21
+ export function mergeEntries(local, fetched) {
22
+ const newByKey = new Map(fetched.map((e) => [e.key, e]));
23
+ const entries = [];
24
+ let updated = 0;
25
+ let keptLocal = 0;
26
+ for (const l of local) {
27
+ const f = newByKey.get(l.key);
28
+ if (!f) {
29
+ keptLocal += 1;
30
+ entries.push(l);
31
+ continue;
32
+ }
33
+ if (JSON.stringify(f.value) !== JSON.stringify(l.value))
34
+ updated += 1;
35
+ entries.push(l.comment !== undefined ? { ...f, comment: l.comment } : f);
36
+ newByKey.delete(l.key); // consumed — what remains is genuinely new
37
+ }
38
+ let added = 0;
39
+ for (const f of fetched) {
40
+ if (newByKey.has(f.key)) {
41
+ entries.push(f);
42
+ added += 1;
43
+ }
44
+ }
45
+ return { entries, added, updated, keptLocal };
46
+ }
14
47
  /** Union of source keys per namespace, for "pending" reporting. */
15
48
  function sourceKeysByNamespace(config, cwd) {
16
49
  const byNs = new Map();
@@ -31,16 +64,29 @@ export async function runPull(config, client, opts) {
31
64
  const sourceKeys = sourceKeysByNamespace(config, opts.cwd);
32
65
  const perLocaleFiles = config.files.filter((f) => !getAdapter(f.format).multiLocale);
33
66
  const multiFiles = config.files.filter((f) => getAdapter(f.format).multiLocale);
34
- // Run-wide namespace cache, keyed `${locale}:${namespace}`.
67
+ // Run-wide namespace cache, keyed `${locale}:${namespace}`. A failed fetch
68
+ // is cached as an error instead of aborting the run — one bad namespace
69
+ // must not zero out a 56-file pull (partial progress, exit 5).
35
70
  const nsCache = new Map();
71
+ const nsErrors = new Map();
72
+ let firstError;
36
73
  const fetchNs = async (locale, namespace) => {
37
74
  const cacheKey = `${locale}:${namespace}`;
38
75
  const hit = nsCache.get(cacheKey);
39
76
  if (hit)
40
77
  return hit;
41
- const bundle = await client.getNamespace(locale, namespace);
42
- nsCache.set(cacheKey, bundle.translations);
43
- return bundle.translations;
78
+ if (nsErrors.has(cacheKey))
79
+ return null;
80
+ try {
81
+ const bundle = await client.getNamespace(locale, namespace);
82
+ nsCache.set(cacheKey, bundle.translations);
83
+ return bundle.translations;
84
+ }
85
+ catch (err) {
86
+ firstError ??= err;
87
+ nsErrors.set(cacheKey, err instanceof Error ? err : new Error(String(err)));
88
+ return null;
89
+ }
44
90
  };
45
91
  // Pre-fetch every needed (locale, namespace) bundle concurrently so the slow
46
92
  // network fan-out runs in parallel; the write loops below then read the warm
@@ -75,30 +121,85 @@ export async function runPull(config, client, opts) {
75
121
  const adapter = getAdapter(file.format);
76
122
  const namespace = file.namespace ?? config.namespace;
77
123
  usedNamespaces.add(namespace);
78
- const entries = toEntries(await fetchNs(locale, namespace));
79
124
  const relPath = file.target.replace('{locale}', locale);
80
- // Don't clobber an existing target with an empty result (locked/pending).
81
- // Dry run computes counts but writes nothing.
82
- if (entries.length > 0 && !opts.dryRun) {
83
- const absPath = resolve(opts.cwd, relPath);
84
- mkdirSync(dirname(absPath), { recursive: true });
85
- writeFileSync(absPath, adapter.serialize(entries, { locale, sourceLocale: config.sourceLocale }));
125
+ const translations = await fetchNs(locale, namespace);
126
+ if (translations === null) {
127
+ fileResults.push({
128
+ target: relPath,
129
+ count: 0,
130
+ error: nsErrors.get(`${locale}:${namespace}`).message,
131
+ });
132
+ continue;
133
+ }
134
+ const fetched = toEntries(translations);
135
+ try {
136
+ const absPath = assertInside(opts.cwd, relPath);
137
+ // Default is a non-destructive merge with the existing target: server
138
+ // wins per key, local-only keys survive. `--force` restores the
139
+ // wholesale overwrite.
140
+ let toWrite = fetched;
141
+ let diff;
142
+ if (!opts.force && fetched.length > 0 && existsSync(absPath)) {
143
+ const merged = mergeEntries(adapter.parse(readFileSync(absPath, 'utf8')), fetched);
144
+ toWrite = merged.entries;
145
+ diff = { added: merged.added, updated: merged.updated, keptLocal: merged.keptLocal };
146
+ }
147
+ // Don't clobber an existing target with an empty result (locked/pending).
148
+ // Dry run computes counts but writes nothing.
149
+ if (fetched.length > 0 && !opts.dryRun) {
150
+ mkdirSync(dirname(absPath), { recursive: true });
151
+ writeFileSync(absPath, adapter.serialize(toWrite, {
152
+ locale,
153
+ sourceLocale: config.sourceLocale,
154
+ flat: file.flat,
155
+ }));
156
+ }
157
+ fileResults.push({ target: relPath, count: fetched.length, ...(diff ?? {}) });
158
+ }
159
+ catch (err) {
160
+ firstError ??= err;
161
+ fileResults.push({
162
+ target: relPath,
163
+ count: 0,
164
+ error: err instanceof Error ? err.message : String(err),
165
+ });
86
166
  }
87
- fileResults.push({ target: relPath, count: entries.length });
88
167
  }
89
168
  for (const file of multiFiles) {
90
169
  const adapter = getAdapter(file.format);
91
170
  const namespace = file.namespace ?? config.namespace;
92
171
  usedNamespaces.add(namespace);
93
- const entries = toEntries(await fetchNs(locale, namespace));
94
- if (entries.length > 0) {
95
- const merged = adapter.mergeLocale(catalogContent.get(file) ?? null, entries, locale);
96
- catalogContent.set(file, merged);
172
+ const translations = await fetchNs(locale, namespace);
173
+ if (translations === null) {
174
+ fileResults.push({
175
+ target: file.target,
176
+ count: 0,
177
+ error: nsErrors.get(`${locale}:${namespace}`).message,
178
+ });
179
+ continue;
180
+ }
181
+ const entries = toEntries(translations);
182
+ try {
183
+ if (entries.length > 0) {
184
+ const merged = adapter.mergeLocale(catalogContent.get(file) ?? null, entries, locale);
185
+ catalogContent.set(file, merged);
186
+ }
187
+ fileResults.push({ target: file.target, count: entries.length });
188
+ }
189
+ catch (err) {
190
+ firstError ??= err;
191
+ fileResults.push({
192
+ target: file.target,
193
+ count: 0,
194
+ error: err instanceof Error ? err.message : String(err),
195
+ });
97
196
  }
98
- fileResults.push({ target: file.target, count: entries.length });
99
197
  }
100
198
  let pending = 0;
101
199
  for (const namespace of usedNamespaces) {
200
+ // A failed namespace's state is unknown — don't report its keys pending.
201
+ if (nsErrors.has(`${locale}:${namespace}`))
202
+ continue;
102
203
  const have = new Set(Object.keys(nsCache.get(`${locale}:${namespace}`) ?? {}));
103
204
  for (const key of sourceKeys.get(namespace) ?? new Set()) {
104
205
  if (!have.has(key))
@@ -112,12 +213,30 @@ export async function runPull(config, client, opts) {
112
213
  for (const file of multiFiles) {
113
214
  const content = catalogContent.get(file);
114
215
  if (content != null) {
115
- const absPath = resolve(opts.cwd, file.target);
116
- mkdirSync(dirname(absPath), { recursive: true });
117
- writeFileSync(absPath, content);
216
+ try {
217
+ const absPath = assertInside(opts.cwd, file.target);
218
+ mkdirSync(dirname(absPath), { recursive: true });
219
+ writeFileSync(absPath, content);
220
+ }
221
+ catch (err) {
222
+ firstError ??= err;
223
+ const message = err instanceof Error ? err.message : String(err);
224
+ for (const loc of locales) {
225
+ for (const f of loc.files) {
226
+ if (f.target === file.target && !f.error)
227
+ f.error = message;
228
+ }
229
+ }
230
+ }
118
231
  }
119
232
  }
120
233
  }
234
+ // Partial progress is a result; total failure keeps today's error contract —
235
+ // rethrow the first error so the exit-code mapping is unchanged.
236
+ const all = locales.flatMap((l) => l.files);
237
+ if (all.length > 0 && all.every((f) => f.error) && firstError !== undefined) {
238
+ throw firstError;
239
+ }
121
240
  return { locales, ...(opts.dryRun ? { dryRun: true } : {}) };
122
241
  }
123
242
  //# 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,16 +41,29 @@ 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;
21
49
  }
22
50
  export interface PushFileResult {
23
51
  file: string;
24
52
  count: number;
25
53
  skipped?: boolean;
54
+ /** Set when this file failed (parse or upload) — it was not (fully) pushed. */
55
+ error?: string;
26
56
  }
27
57
  export interface PushResult {
28
58
  mode: 'source' | 'target';
59
+ /**
60
+ * Keys the CLI SENT. Not the number stored — the server queues them, so
61
+ * `jobs` is what says whether they landed.
62
+ */
29
63
  pushed: number;
30
64
  files: PushFileResult[];
65
+ /** Jobs the server queued for this push (source mode only; empty on a dry run). */
66
+ jobs: PushedJob[];
31
67
  /** True when `--publish` was requested but no editor token was available. */
32
68
  publishSkipped?: boolean;
33
69
  /** True when this was a dry run — nothing was sent to the server. */