@lionrapid/cli 0.2.0 → 0.2.2

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +38 -3
  3. package/dist/adapters/android-xml.js +29 -6
  4. package/dist/adapters/json.d.ts +2 -2
  5. package/dist/adapters/json.js +4 -3
  6. package/dist/adapters/nested.d.ts +7 -1
  7. package/dist/adapters/nested.js +23 -2
  8. package/dist/adapters/types.d.ts +2 -0
  9. package/dist/adapters/yaml.js +3 -3
  10. package/dist/api-client.d.ts +32 -0
  11. package/dist/api-client.js +106 -6
  12. package/dist/cli.d.ts +1 -2
  13. package/dist/cli.js +240 -21
  14. package/dist/commands/context.d.ts +9 -0
  15. package/dist/commands/context.js +28 -2
  16. package/dist/commands/doctor.d.ts +2 -1
  17. package/dist/commands/doctor.js +8 -4
  18. package/dist/commands/init.d.ts +44 -0
  19. package/dist/commands/init.js +0 -0
  20. package/dist/commands/login.d.ts +47 -0
  21. package/dist/commands/login.js +110 -0
  22. package/dist/commands/pull.d.ts +22 -0
  23. package/dist/commands/pull.js +140 -21
  24. package/dist/commands/push.d.ts +2 -0
  25. package/dist/commands/push.js +39 -16
  26. package/dist/commands/snapshot.js +3 -2
  27. package/dist/commands/types.js +2 -1
  28. package/dist/commands/validate.d.ts +5 -2
  29. package/dist/commands/validate.js +15 -2
  30. package/dist/config.d.ts +46 -1
  31. package/dist/config.js +209 -14
  32. package/dist/types-gen/extract.js +7 -1
  33. package/dist/types-gen/generate.js +9 -1
  34. package/package.json +2 -1
  35. package/dist/adapters/android-xml.d.ts.map +0 -1
  36. package/dist/adapters/android-xml.js.map +0 -1
  37. package/dist/adapters/arb.d.ts.map +0 -1
  38. package/dist/adapters/arb.js.map +0 -1
  39. package/dist/adapters/content.d.ts.map +0 -1
  40. package/dist/adapters/content.js.map +0 -1
  41. package/dist/adapters/index.d.ts.map +0 -1
  42. package/dist/adapters/index.js.map +0 -1
  43. package/dist/adapters/ios-strings.d.ts.map +0 -1
  44. package/dist/adapters/ios-strings.js.map +0 -1
  45. package/dist/adapters/json.d.ts.map +0 -1
  46. package/dist/adapters/json.js.map +0 -1
  47. package/dist/adapters/nested.d.ts.map +0 -1
  48. package/dist/adapters/nested.js.map +0 -1
  49. package/dist/adapters/po.d.ts.map +0 -1
  50. package/dist/adapters/po.js.map +0 -1
  51. package/dist/adapters/types.d.ts.map +0 -1
  52. package/dist/adapters/types.js.map +0 -1
  53. package/dist/adapters/xcstrings.d.ts.map +0 -1
  54. package/dist/adapters/xcstrings.js.map +0 -1
  55. package/dist/adapters/xliff.d.ts.map +0 -1
  56. package/dist/adapters/xliff.js.map +0 -1
  57. package/dist/adapters/yaml.d.ts.map +0 -1
  58. package/dist/adapters/yaml.js.map +0 -1
  59. package/dist/api-client.d.ts.map +0 -1
  60. package/dist/api-client.js.map +0 -1
  61. package/dist/cli.d.ts.map +0 -1
  62. package/dist/cli.js.map +0 -1
  63. package/dist/commands/context.d.ts.map +0 -1
  64. package/dist/commands/context.js.map +0 -1
  65. package/dist/commands/doctor.d.ts.map +0 -1
  66. package/dist/commands/doctor.js.map +0 -1
  67. package/dist/commands/init.d.ts.map +0 -1
  68. package/dist/commands/init.js.map +0 -1
  69. package/dist/commands/pull.d.ts.map +0 -1
  70. package/dist/commands/pull.js.map +0 -1
  71. package/dist/commands/push.d.ts.map +0 -1
  72. package/dist/commands/push.js.map +0 -1
  73. package/dist/commands/scan.d.ts.map +0 -1
  74. package/dist/commands/scan.js.map +0 -1
  75. package/dist/commands/snapshot.d.ts.map +0 -1
  76. package/dist/commands/snapshot.js.map +0 -1
  77. package/dist/commands/types.d.ts.map +0 -1
  78. package/dist/commands/types.js.map +0 -1
  79. package/dist/commands/validate.d.ts.map +0 -1
  80. package/dist/commands/validate.js.map +0 -1
  81. package/dist/config.d.ts.map +0 -1
  82. package/dist/config.js.map +0 -1
  83. package/dist/exit-codes.d.ts.map +0 -1
  84. package/dist/exit-codes.js.map +0 -1
  85. package/dist/index.d.ts.map +0 -1
  86. package/dist/index.js.map +0 -1
  87. package/dist/output.d.ts.map +0 -1
  88. package/dist/output.js.map +0 -1
  89. package/dist/scan/canonical.d.ts.map +0 -1
  90. package/dist/scan/canonical.js.map +0 -1
  91. package/dist/scan/discover.d.ts.map +0 -1
  92. package/dist/scan/discover.js.map +0 -1
  93. package/dist/scan/scanner.d.ts.map +0 -1
  94. package/dist/scan/scanner.js.map +0 -1
  95. package/dist/types-gen/extract.d.ts.map +0 -1
  96. package/dist/types-gen/extract.js.map +0 -1
  97. package/dist/types-gen/generate.d.ts.map +0 -1
  98. package/dist/types-gen/generate.js.map +0 -1
@@ -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
@@ -23,6 +23,8 @@ export interface PushFileResult {
23
23
  file: string;
24
24
  count: number;
25
25
  skipped?: boolean;
26
+ /** Set when this file failed (parse or upload) — it was not (fully) pushed. */
27
+ error?: string;
26
28
  }
27
29
  export interface PushResult {
28
30
  mode: 'source' | 'target';
@@ -13,6 +13,8 @@ import { resolve } from 'node:path';
13
13
  import { ConfigError } from '../config.js';
14
14
  import { getAdapter } from '../adapters/index.js';
15
15
  import { chunk } from './context.js';
16
+ // The server's enforced ceiling is 1000 items per sync/targets batch (Pydantic
17
+ // `max_length` on the request model) — 500 stays comfortably under it.
16
18
  const BATCH = 500;
17
19
  export async function runPush(config, client, opts) {
18
20
  const mode = opts.target ? 'target' : 'source';
@@ -32,6 +34,7 @@ export async function runPush(config, client, opts) {
32
34
  const canPublish = mode === 'target' && opts.publish === true && client.canPublish;
33
35
  const publishSkipped = mode === 'target' && opts.publish === true && !client.canPublish;
34
36
  let pushed = 0;
37
+ let firstError;
35
38
  for (const file of config.files) {
36
39
  const adapter = getAdapter(file.format);
37
40
  const namespace = file.namespace ?? config.namespace;
@@ -41,27 +44,47 @@ export async function runPush(config, client, opts) {
41
44
  files.push({ file: relPath, count: 0, skipped: true });
42
45
  continue;
43
46
  }
44
- const parseLocale = mode === 'source' ? config.sourceLocale : opts.target;
45
- const entries = adapter.parse(readFileSync(absPath, 'utf8'), { locale: parseLocale });
46
- const items = entries.map((e) => ({ key: e.key, value: e.value }));
47
- // Dry run: parse + count only, never touch the network.
48
- if (!opts.dryRun) {
49
- for (const batch of chunk(items, BATCH)) {
50
- if (mode === 'source') {
51
- await client.syncBatch(config.sourceLocale, namespace, batch);
52
- }
53
- else {
54
- await client.targetsBatch(opts.target, namespace, batch);
55
- if (canPublish) {
56
- for (const it of batch) {
57
- await client.markReady(opts.target, namespace, it.key);
47
+ // One bad file/namespace must not zero out the rest of the push (a rate
48
+ // limit on a many-namespace site, one corrupt file). Record the failure
49
+ // and move on — sync/batch upserts by composite key, so re-running push
50
+ // retries just the failures (partial progress, exit 5).
51
+ try {
52
+ const parseLocale = mode === 'source' ? config.sourceLocale : opts.target;
53
+ const entries = adapter.parse(readFileSync(absPath, 'utf8'), { locale: parseLocale });
54
+ const items = entries.map((e) => ({ key: e.key, value: e.value }));
55
+ // Dry run: parse + count only, never touch the network.
56
+ if (!opts.dryRun) {
57
+ for (const batch of chunk(items, BATCH)) {
58
+ if (mode === 'source') {
59
+ await client.syncBatch(config.sourceLocale, namespace, batch);
60
+ }
61
+ else {
62
+ await client.targetsBatch(opts.target, namespace, batch);
63
+ if (canPublish) {
64
+ for (const it of batch) {
65
+ await client.markReady(opts.target, namespace, it.key);
66
+ }
58
67
  }
59
68
  }
60
69
  }
61
70
  }
71
+ pushed += items.length;
72
+ files.push({ file: relPath, count: items.length });
62
73
  }
63
- pushed += items.length;
64
- files.push({ file: relPath, count: items.length });
74
+ catch (err) {
75
+ firstError ??= err;
76
+ files.push({
77
+ file: relPath,
78
+ count: 0,
79
+ error: err instanceof Error ? err.message : String(err),
80
+ });
81
+ }
82
+ }
83
+ // Partial progress is a result; total failure keeps today's error contract —
84
+ // rethrow the first error so the exit-code mapping is unchanged.
85
+ const attempted = files.filter((f) => !f.skipped);
86
+ if (attempted.length > 0 && attempted.every((f) => f.error) && firstError !== undefined) {
87
+ throw firstError;
65
88
  }
66
89
  return {
67
90
  mode,
@@ -13,7 +13,8 @@
13
13
  * surface with no re-derivation.
14
14
  */
15
15
  import { mkdirSync, writeFileSync } from 'node:fs';
16
- import { dirname, resolve } from 'node:path';
16
+ import { dirname } from 'node:path';
17
+ import { assertInside } from './context.js';
17
18
  export const DEFAULT_SNAPSHOT_DIR = 'snapshots';
18
19
  export async function runSnapshot(config, client, opts) {
19
20
  const out = opts.out ?? DEFAULT_SNAPSHOT_DIR;
@@ -41,7 +42,7 @@ export async function runSnapshot(config, client, opts) {
41
42
  translations: bundle.translations, // raw — styles preserved
42
43
  };
43
44
  const rel = `${out}/${locale}.${namespace}.json`;
44
- const abs = resolve(opts.cwd, rel);
45
+ const abs = assertInside(opts.cwd, rel);
45
46
  mkdirSync(dirname(abs), { recursive: true });
46
47
  writeFileSync(abs, `${JSON.stringify(snapshot, null, 2)}\n`);
47
48
  files.push({ path: rel, locale, namespace, keys });
@@ -14,6 +14,7 @@ import { dirname, resolve } from 'node:path';
14
14
  import { getAdapter } from '../adapters/index.js';
15
15
  import { generateDts } from '../types-gen/generate.js';
16
16
  import { extractParams, IcuError } from '../types-gen/extract.js';
17
+ import { assertInside } from './context.js';
17
18
  const DEFAULT_OUTPUT = 'types/translations.d.ts';
18
19
  /** Map every source key to its ICU message text (first definition wins). */
19
20
  export function collectSourceKeyTexts(config, cwd) {
@@ -50,7 +51,7 @@ export async function collectServerKeyTexts(config, client) {
50
51
  function writeDts(keyTexts, config, opts) {
51
52
  const dts = generateDts(keyTexts, { strictIcu: opts.strictIcu });
52
53
  const output = opts.output ?? config.typesOutput ?? DEFAULT_OUTPUT;
53
- const abs = resolve(opts.cwd, output);
54
+ const abs = assertInside(opts.cwd, output);
54
55
  mkdirSync(dirname(abs), { recursive: true });
55
56
  writeFileSync(abs, dts);
56
57
  return { keys: keyTexts.size, output };