i18next-cli 1.61.0 → 1.62.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.
Files changed (48) hide show
  1. package/README.md +119 -1
  2. package/dist/cjs/cli.js +36 -5
  3. package/dist/cjs/config.js +5 -1
  4. package/dist/cjs/index.js +6 -0
  5. package/dist/cjs/init.js +9 -75
  6. package/dist/cjs/instrumenter/core/instrumenter.js +32 -11
  7. package/dist/cjs/instrumenter/core/transformer.js +5 -1
  8. package/dist/cjs/localize/agent-prompt.js +49 -0
  9. package/dist/cjs/localize/detect.js +88 -0
  10. package/dist/cjs/localize/localize.js +475 -0
  11. package/dist/cjs/locize.js +91 -13
  12. package/dist/cjs/status.js +5 -1
  13. package/dist/cjs/types-generator.js +8 -3
  14. package/dist/cjs/utils/file-utils.js +6 -2
  15. package/dist/cjs/utils/locize-onboarding.js +91 -0
  16. package/dist/cjs/utils/wrap-ora.js +9 -5
  17. package/dist/esm/cli.js +29 -2
  18. package/dist/esm/index.js +4 -0
  19. package/dist/esm/init.js +3 -73
  20. package/dist/esm/instrumenter/core/instrumenter.js +21 -8
  21. package/dist/esm/localize/agent-prompt.js +47 -0
  22. package/dist/esm/localize/detect.js +85 -0
  23. package/dist/esm/localize/localize.js +469 -0
  24. package/dist/esm/locize.js +82 -11
  25. package/dist/esm/utils/locize-onboarding.js +83 -0
  26. package/package.json +10 -10
  27. package/types/cli.d.ts.map +1 -1
  28. package/types/index.d.ts +2 -0
  29. package/types/index.d.ts.map +1 -1
  30. package/types/init.d.ts.map +1 -1
  31. package/types/instrumenter/core/instrumenter.d.ts +27 -0
  32. package/types/instrumenter/core/instrumenter.d.ts.map +1 -1
  33. package/types/instrumenter/index.d.ts +2 -1
  34. package/types/instrumenter/index.d.ts.map +1 -1
  35. package/types/localize/agent-prompt.d.ts +11 -0
  36. package/types/localize/agent-prompt.d.ts.map +1 -0
  37. package/types/localize/detect.d.ts +37 -0
  38. package/types/localize/detect.d.ts.map +1 -0
  39. package/types/localize/index.d.ts +6 -0
  40. package/types/localize/index.d.ts.map +1 -0
  41. package/types/localize/localize.d.ts +20 -0
  42. package/types/localize/localize.d.ts.map +1 -0
  43. package/types/locize.d.ts +20 -0
  44. package/types/locize.d.ts.map +1 -1
  45. package/types/types.d.ts +12 -0
  46. package/types/types.d.ts.map +1 -1
  47. package/types/utils/locize-onboarding.d.ts +19 -0
  48. package/types/utils/locize-onboarding.d.ts.map +1 -0
@@ -0,0 +1,475 @@
1
+ 'use strict';
2
+
3
+ var node_util = require('node:util');
4
+ var promises = require('node:fs/promises');
5
+ var execa = require('execa');
6
+ var inquirer = require('inquirer');
7
+ var glob = require('glob');
8
+ var config = require('../config.js');
9
+ var heuristicConfig = require('../heuristic-config.js');
10
+ var extractor = require('../extractor/core/extractor.js');
11
+ require('node:module');
12
+ require('node:path');
13
+ var nestedObject = require('../utils/nested-object.js');
14
+ require('jiti');
15
+ require('@croct/json5-parser');
16
+ require('yaml');
17
+ var instrumenter = require('../instrumenter/core/instrumenter.js');
18
+ require('../utils/jsx-attributes.js');
19
+ require('magic-string');
20
+ var locize = require('../locize.js');
21
+ var locizeOnboarding = require('../utils/locize-onboarding.js');
22
+ var detect = require('./detect.js');
23
+ var agentPrompt = require('./agent-prompt.js');
24
+
25
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
26
+
27
+ var inquirer__default = /*#__PURE__*/_interopDefault(inquirer);
28
+
29
+ const LOCIZE_SIGNUP_URL = 'https://www.locize.app/register?from=i18next_cli__localize';
30
+ const TOTAL_STEPS = 6;
31
+ /**
32
+ * Server-side errors indicating auto-translation is not enabled/available on
33
+ * the project: the output must both mention auto-translation/MT and describe
34
+ * a disabled state. Checked against the captured stderr/stdout only — never
35
+ * `error.message`, which can echo the invoked command line (and therefore
36
+ * always contains "--auto-translate").
37
+ */
38
+ const AI_MENTION_PATTERN = /auto.?translat|machine translation/i;
39
+ const AI_DISABLED_PATTERN = /not (enabled|allowed|activated|available)|disabled/i;
40
+ /** Delays between status-poll rounds while waiting for async AI translation (rounds = delays + 1). */
41
+ const POLL_DELAYS_MS = [15000, 20000];
42
+ function step(n, label) {
43
+ console.log(node_util.styleText('bold', `\n[${n}/${TOTAL_STEPS}] ${label}`));
44
+ }
45
+ function ok(message) {
46
+ console.log(node_util.styleText('green', ` ✔ ${message}`));
47
+ }
48
+ function warn(message) {
49
+ console.log(node_util.styleText('yellow', ` ⚠ ${message}`));
50
+ }
51
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
52
+ /**
53
+ * Checks the git working tree. Returns:
54
+ * - true: dirty (uncommitted changes)
55
+ * - false: clean
56
+ * - null: not a git repository (or git unavailable)
57
+ */
58
+ async function isGitTreeDirty() {
59
+ try {
60
+ const { stdout } = await execa.execa('git', ['status', '--porcelain']);
61
+ return stdout.trim().length > 0;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ /**
68
+ * Computes per-locale translation completeness from the local translation
69
+ * files (post-download these mirror the remote state). Intentionally
70
+ * tolerant: non-JSON output formats or unreadable files simply yield no data
71
+ * — this powers an informational summary, not a gate.
72
+ */
73
+ async function computeCompleteness(config) {
74
+ const output = config.extract.output;
75
+ if (typeof output !== 'string')
76
+ return [];
77
+ const primary = config.extract.primaryLanguage || config.locales[0] || 'en';
78
+ const secondaries = config.locales.filter(l => l !== primary);
79
+ const rawSep = config.extract.keySeparator;
80
+ const keySeparator = rawSep === false ? false : (rawSep ?? '.');
81
+ const primaryTemplate = output.replace('{{language}}', primary);
82
+ const hasNamespace = primaryTemplate.includes('{{namespace}}');
83
+ const primaryFiles = hasNamespace
84
+ ? await glob.glob(primaryTemplate.replace('{{namespace}}', '*'), { nodir: true })
85
+ : [primaryTemplate];
86
+ const readJson = async (path) => {
87
+ try {
88
+ return JSON.parse(await promises.readFile(path, 'utf-8'));
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ };
94
+ // namespace → flattened primary keys
95
+ const namespaces = new Map();
96
+ const [prefix, suffix] = hasNamespace
97
+ ? primaryTemplate.replace(/\\/g, '/').split('{{namespace}}')
98
+ : ['', ''];
99
+ for (const file of primaryFiles) {
100
+ const json = await readJson(file);
101
+ if (!json)
102
+ continue;
103
+ const normalized = file.replace(/\\/g, '/');
104
+ const ns = hasNamespace
105
+ ? normalized.slice(prefix.length, suffix ? normalized.length - suffix.length : undefined)
106
+ : '';
107
+ namespaces.set(ns, nestedObject.getNestedKeys(json, keySeparator));
108
+ }
109
+ // No parseable primary files (e.g. yaml/js output formats): make no claim
110
+ // rather than reporting every language as 0/0 = 100% complete.
111
+ if (namespaces.size === 0)
112
+ return [];
113
+ const totalKeys = [...namespaces.values()].reduce((sum, keys) => sum + keys.length, 0);
114
+ const results = [];
115
+ for (const locale of secondaries) {
116
+ let translated = 0;
117
+ for (const [ns, keys] of namespaces) {
118
+ const path = output.replace('{{language}}', locale).replace('{{namespace}}', ns);
119
+ const json = await readJson(path);
120
+ if (!json)
121
+ continue;
122
+ for (const key of keys) {
123
+ const value = nestedObject.getNestedValue(json, key, keySeparator);
124
+ if (typeof value === 'string' ? value.trim() !== '' : (value !== undefined && value !== null)) {
125
+ translated++;
126
+ }
127
+ }
128
+ }
129
+ results.push({ locale, translated, total: totalKeys });
130
+ }
131
+ return results;
132
+ }
133
+ function printCompleteness(completeness) {
134
+ for (const { locale, translated, total } of completeness) {
135
+ const pct = total === 0 ? 100 : Math.round((translated / total) * 100);
136
+ const color = pct === 100 ? 'green' : pct > 0 ? 'yellow' : 'red';
137
+ console.log(node_util.styleText(color, ` ${locale}: ${translated}/${total} (${pct}%)`));
138
+ }
139
+ }
140
+ function printEpilogue(config) {
141
+ const projectId = config.locize?.projectId || '<your-project-id>';
142
+ console.log(node_util.styleText('green', '\n✅ Done. Your app is localized.'));
143
+ console.log(node_util.styleText('cyan', '\nYour AI translations come with confidence scores; low-confidence ones are flagged for review in Locize.'));
144
+ console.log('\nOptional — switch to CDN delivery so translation fixes go live without redeploying your app:');
145
+ console.log(node_util.styleText('cyan', ' npm install i18next-locize-backend'));
146
+ console.log(node_util.styleText('gray', `
147
+ // in your i18n init file:
148
+ import LocizeBackend from 'i18next-locize-backend'
149
+
150
+ i18next
151
+ .use(LocizeBackend)
152
+ .init({
153
+ backend: {
154
+ projectId: '${projectId}',
155
+ version: 'latest', // no apiKey in production!
156
+ },
157
+ // ...
158
+ })
159
+ `));
160
+ console.log('Docs: https://github.com/locize/i18next-locize-backend');
161
+ console.log('Your current setup (local translation files) keeps working either way; run `i18next-cli locize-download` in CI to refresh the files.');
162
+ }
163
+ /**
164
+ * The `localize` supercommand: takes a mono-lingual app to fully localized +
165
+ * delivered via Locize in one command, orchestrating the existing pieces —
166
+ * detect → instrument → extract → connect Locize → sync with AI
167
+ * auto-translate → download & verify.
168
+ */
169
+ async function runLocalize(options = {}, configPath) {
170
+ if (options.printAgentPrompt) {
171
+ console.log(agentPrompt.AGENT_PROMPT);
172
+ return;
173
+ }
174
+ const isDryRun = !!options.dryRun;
175
+ const isCi = !!options.ci;
176
+ const autoYes = !!options.yes;
177
+ const interactive = !isCi && !autoYes;
178
+ console.log(node_util.styleText('bold', 'i18next-cli localize — from hardcoded strings to a localized app'));
179
+ // ── [1/6] Detect ────────────────────────────────────────────────────────
180
+ step(1, 'Detecting project…');
181
+ const stack = await detect.detectStack(instrumenter.findExistingI18nInitFile);
182
+ ok(`${stack.framework === 'unknown' ? 'unknown framework' : stack.framework}${stack.hasAppRouter ? ' (App Router)' : ''}${stack.hasTypeScript ? ' + TypeScript' : ''}${stack.hasI18next ? ', i18next detected' : ''}`);
183
+ let skipInstrument = !!options.skipInstrument;
184
+ if (stack.hasParaglide) {
185
+ warn('This app uses inlang Paraglide — instrumenting i18next calls would conflict; Locize can still manage these translations.');
186
+ if (!stack.hasI18next) {
187
+ console.log(node_util.styleText('yellow', '\nNo i18next setup found alongside Paraglide, so there is nothing for `localize` to do here.'));
188
+ console.log('To manage Paraglide translations with Locize, see https://www.locize.com — or set up i18next first and re-run.');
189
+ return;
190
+ }
191
+ skipInstrument = true;
192
+ }
193
+ // ── [2/6] Configuration ─────────────────────────────────────────────────
194
+ step(2, 'Configuration…');
195
+ let config$1;
196
+ if (isCi) {
197
+ let loaded = await config.loadConfig(configPath);
198
+ if (!loaded) {
199
+ const detected = await heuristicConfig.detectConfig();
200
+ if (!detected) {
201
+ console.error(node_util.styleText('red', 'No i18next.config found.'));
202
+ console.log('Run `npx i18next-cli init` locally and commit the config, or pass `--config <path>`.');
203
+ process.exit(1);
204
+ return;
205
+ }
206
+ loaded = detected;
207
+ }
208
+ config$1 = loaded;
209
+ }
210
+ else {
211
+ config$1 = await config.ensureConfig(configPath);
212
+ }
213
+ ok(`locales: ${config$1.locales.join(', ')}`);
214
+ // Instrument eligibility: React/Next natively, other stacks via a configured plugin
215
+ const instrumentableNatively = stack.framework === 'react' || stack.framework === 'next';
216
+ const stackPluginConfigured = detect.hasStackPlugin(config$1, stack.framework);
217
+ if (!skipInstrument && !instrumentableNatively && !stackPluginConfigured) {
218
+ warn(`instrument transforms React/JSX out of the box${stack.framework !== 'unknown' ? ` — detected ${stack.framework}` : ''}.`);
219
+ console.log(` For ${stack.framework === 'vue' ? 'Vue, add a plugin to i18next.config.ts — community: i18next-cli-vue' : stack.framework === 'svelte' ? 'Svelte, add a plugin to i18next.config.ts — community: i18next-cli-plugin-svelte' : 'this stack, add a plugin to i18next.config.ts'} — or write your own via the instrumentOnLoad/onLoad hooks (see the Plugin System docs). Then re-run \`i18next-cli localize\`.`);
220
+ console.log(' Without a plugin: wrap strings manually (`i18next-cli lint` lists them) and re-run with `--skip-instrument`.');
221
+ skipInstrument = true;
222
+ }
223
+ // ── [3/6] Instrument ────────────────────────────────────────────────────
224
+ step(3, 'Instrumenting code…');
225
+ if (!skipInstrument && isCi && !autoYes) {
226
+ warn('Skipped in CI: instrumentation rewrites source files and needs human review. Run `i18next-cli localize` locally, or pass `--ci --yes` to force.');
227
+ skipInstrument = true;
228
+ }
229
+ if (skipInstrument) {
230
+ if (options.skipInstrument)
231
+ ok('Skipped (--skip-instrument).');
232
+ }
233
+ else {
234
+ // Dirty-git guard: instrument rewrites source files — make sure the diff is reviewable.
235
+ if (!isDryRun) {
236
+ const dirty = await isGitTreeDirty();
237
+ if (dirty === null) {
238
+ warn("Not a git repository — you won't be able to review instrument's changes as a diff.");
239
+ }
240
+ else if (dirty) {
241
+ if (interactive) {
242
+ const { proceed } = await inquirer__default.default.prompt([{
243
+ type: 'confirm',
244
+ name: 'proceed',
245
+ message: 'Working tree has uncommitted changes. instrument rewrites source files — continue?',
246
+ default: false,
247
+ }]);
248
+ if (!proceed) {
249
+ console.log('Aborted. Commit or stash your changes, then re-run `i18next-cli localize`.');
250
+ process.exit(1);
251
+ return;
252
+ }
253
+ }
254
+ else {
255
+ warn('Working tree has uncommitted changes — instrument changes will mix into your diff.');
256
+ }
257
+ }
258
+ }
259
+ // Already-using-i18next heuristic (instrument is idempotent either way)
260
+ if (stack.hasI18next && stack.initFile && interactive) {
261
+ const { proceed } = await inquirer__default.default.prompt([{
262
+ type: 'confirm',
263
+ name: 'proceed',
264
+ message: `Your project already uses i18next (found ${stack.initFile}). Run instrumentation anyway to catch remaining hardcoded strings?`,
265
+ default: true,
266
+ }]);
267
+ if (!proceed)
268
+ skipInstrument = true;
269
+ }
270
+ if (stack.hasAppRouter) {
271
+ warn("Next.js App Router detected: instrument injects useTranslation(), which is client-only. Review the diff for server components — add 'use client' or switch those to a server-side t() pattern.");
272
+ }
273
+ if (!skipInstrument) {
274
+ const results = await instrumenter.runInstrumenter(config$1, {
275
+ isDryRun,
276
+ isInteractive: interactive,
277
+ namespace: options.namespace,
278
+ quiet: false,
279
+ });
280
+ if (results.totalCandidates === 0) {
281
+ ok('No hardcoded strings found — your code looks already internationalized.');
282
+ }
283
+ else {
284
+ ok(`${results.totalTransformed}/${results.totalCandidates} candidate string(s) ${isDryRun ? 'would be ' : ''}instrumented (${results.totalSkipped} skipped).`);
285
+ }
286
+ }
287
+ }
288
+ // ── [4/6] Extract ───────────────────────────────────────────────────────
289
+ step(4, 'Extracting translation keys…');
290
+ const { hasErrors } = await extractor.runExtractor(config$1, { isDryRun, quiet: false });
291
+ if (hasErrors) {
292
+ console.error(node_util.styleText('red', '\nExtraction reported errors (see above).'));
293
+ console.log('Fix the parse errors, then re-run `i18next-cli localize` — completed steps are skipped automatically on re-run.');
294
+ process.exit(1);
295
+ return;
296
+ }
297
+ ok(isDryRun ? 'Extraction previewed (dry-run).' : 'Translation keys extracted.');
298
+ if (options.skipLocize) {
299
+ console.log(node_util.styleText('green', '\n✅ Done (local files only — steps 5–6 skipped via --skip-locize).'));
300
+ console.log('When you are ready for managed translations + AI auto-translate, re-run without --skip-locize.');
301
+ return;
302
+ }
303
+ // ── [5/6] Connect Locize ────────────────────────────────────────────────
304
+ step(5, 'Connecting to Locize…');
305
+ let projectId = config$1.locize?.projectId || process.env.LOCIZE_PROJECTID || process.env.LOCIZE_PID;
306
+ let apiKey = config$1.locize?.apiKey || process.env.LOCIZE_API_KEY || process.env.LOCIZE_KEY;
307
+ if (projectId && apiKey) {
308
+ ok(`Project ${projectId} (API key ${locize.maskApiKey(apiKey)})`);
309
+ config$1.locize = { ...config$1.locize, projectId, apiKey };
310
+ }
311
+ else if (isCi) {
312
+ console.error(node_util.styleText('red', 'Missing Locize credentials.'));
313
+ console.log('Set the LOCIZE_PROJECTID and LOCIZE_API_KEY environment variables (Project settings → "API, CDN, NOTIFICATIONS" tab on www.locize.app), or add locize.projectId to i18next.config.ts.');
314
+ process.exit(1);
315
+ return;
316
+ }
317
+ else if (isDryRun) {
318
+ warn('No Locize credentials configured — with credentials, step 6 would sync your keys and request AI auto-translation.');
319
+ console.log(node_util.styleText('blue', '\n📋 Dry run complete — re-run without --dry-run to apply.'));
320
+ return;
321
+ }
322
+ else {
323
+ console.log(`
324
+ One manual step — in your browser:
325
+ 1. Sign up / log in: ${LOCIZE_SIGNUP_URL}
326
+ 2. Create a project.
327
+ Your target languages (${config$1.locales.join(', ')}) are created
328
+ automatically on the first sync. Auto-translate and Quality Estimation
329
+ are on by default for new projects: translations with confidence scores
330
+ arrive automatically once the project is subscribed or an AI/MT provider
331
+ is configured; low-confidence ones are flagged for review.
332
+ 3. Copy your Project ID and an API key from Project settings →
333
+ "API, CDN, NOTIFICATIONS" tab (any write-capable key works).
334
+ `);
335
+ const opened = await locizeOnboarding.openBrowser(LOCIZE_SIGNUP_URL, { ci: isCi });
336
+ if (!opened) {
337
+ console.log(` 👉 Open this URL manually: ${LOCIZE_SIGNUP_URL}\n`);
338
+ }
339
+ const credentials = await locizeOnboarding.promptLocizeCredentials();
340
+ if (!credentials.apiKey) {
341
+ console.error(node_util.styleText('red', '\nAn API key is required to sync translations.'));
342
+ console.log('Your code is instrumented and keys are extracted — re-run `i18next-cli localize` anytime to finish, or use `--skip-locize`.');
343
+ process.exit(1);
344
+ return;
345
+ }
346
+ projectId = credentials.projectId;
347
+ apiKey = credentials.apiKey;
348
+ config$1.locize = { ...config$1.locize, projectId, apiKey };
349
+ console.log(node_util.styleText('cyan', '\nTo persist these credentials for future runs:'));
350
+ console.log(node_util.styleText('green', `
351
+ # .env (add to .gitignore!)
352
+ LOCIZE_API_KEY=${apiKey}
353
+ `));
354
+ console.log(node_util.styleText('green', ` // i18next.config.ts
355
+ locize: {
356
+ projectId: '${projectId}',
357
+ apiKey: process.env.LOCIZE_API_KEY,
358
+ },
359
+ `));
360
+ }
361
+ // ── [6/6] Translate & deliver ───────────────────────────────────────────
362
+ step(6, 'Translating & delivering…');
363
+ const autoTranslate = options.skipTranslate ? undefined : true;
364
+ try {
365
+ await locize.runLocizeSync(config$1, {
366
+ autoTranslate,
367
+ updateValues: options.updateValues,
368
+ cdnType: options.cdnType,
369
+ dryRun: isDryRun,
370
+ throwOnError: true,
371
+ });
372
+ }
373
+ catch (error) {
374
+ const capturedOutput = error instanceof locize.LocizeCommandError ? `${error.stderr}\n${error.stdout}` : '';
375
+ if (autoTranslate && AI_MENTION_PATTERN.test(capturedOutput) && AI_DISABLED_PATTERN.test(capturedOutput)) {
376
+ warn('Locize rejected auto-translation — AI/MT is not enabled on this project.');
377
+ // Retry once without auto-translate so the key sync itself completes.
378
+ try {
379
+ await locize.runLocizeSync(config$1, {
380
+ updateValues: options.updateValues,
381
+ cdnType: options.cdnType,
382
+ dryRun: isDryRun,
383
+ throwOnError: true,
384
+ });
385
+ ok('Keys synced to Locize (without auto-translation).');
386
+ }
387
+ catch (retryError) {
388
+ console.error(node_util.styleText('red', `Sync failed: ${retryError.message}`));
389
+ }
390
+ console.log(`
391
+ Enable it: www.locize.app → your project → Settings →
392
+ "EDITOR, TM/MT/AI, ORDERING" tab → turn on the Automatic Translation Workflow.
393
+ Then re-run \`i18next-cli localize\` (or \`i18next-cli locize-sync --auto-translate true\`).
394
+ `);
395
+ process.exit(1);
396
+ return;
397
+ }
398
+ if (error instanceof locize.LocizeCommandError && /missing required argument/i.test(error.stderr)) {
399
+ console.error(node_util.styleText('red', 'Locize rejected the credentials.'));
400
+ console.log('Check the API key in Project settings → "API, CDN, NOTIFICATIONS" tab (any write-capable key works for new projects; readonly keys cannot sync).');
401
+ process.exit(1);
402
+ return;
403
+ }
404
+ // Empty-project case (project exists but has no languages yet): newer
405
+ // locize-cli versions bootstrap the languages automatically during sync —
406
+ // guide users running an older global install. The wrong-cdnType variant
407
+ // of this error is actionable as-is and passes through below.
408
+ if (error instanceof locize.LocizeCommandError &&
409
+ /Project with id .* not found/i.test(capturedOutput) &&
410
+ !/wrong cdnType/i.test(capturedOutput)) {
411
+ console.error(node_util.styleText('red', 'The Locize project was not found — or it has no languages yet.'));
412
+ console.log('If the project ID is correct, your project has no languages yet:');
413
+ console.log(' - update locize-cli (`npm i -g locize-cli`) so sync can create them automatically, or');
414
+ console.log(' - add your languages in the project settings on www.locize.app,');
415
+ console.log('then re-run `i18next-cli localize`.');
416
+ process.exit(1);
417
+ return;
418
+ }
419
+ console.error(node_util.styleText('red', `Sync failed: ${error.message}`));
420
+ process.exit(1);
421
+ return;
422
+ }
423
+ if (isDryRun) {
424
+ console.log(node_util.styleText('blue', '\n📋 Dry run complete — re-run without --dry-run to apply.'));
425
+ return;
426
+ }
427
+ ok(`Synced to Locize${autoTranslate ? ' with AI auto-translate requested' : ''}.`);
428
+ // Poll-then-download: AI translation is asynchronous server-side — watch
429
+ // the translations arrive instead of downloading a still-empty snapshot.
430
+ const isComplete = (completeness) => completeness.length > 0 && completeness.every(c => c.translated >= c.total);
431
+ let downloadFailed = false;
432
+ const download = async () => {
433
+ try {
434
+ await locize.runLocizeDownload(config$1, { cdnType: options.cdnType, throwOnError: true });
435
+ return true;
436
+ }
437
+ catch (error) {
438
+ downloadFailed = true;
439
+ warn(`Download failed: ${error.message}`);
440
+ return false;
441
+ }
442
+ };
443
+ let completeness = [];
444
+ if (autoTranslate && !isCi) {
445
+ console.log(node_util.styleText('cyan', ' Waiting for AI translations to arrive…'));
446
+ for (let round = 0; round <= POLL_DELAYS_MS.length; round++) {
447
+ if (!await download())
448
+ break;
449
+ completeness = await computeCompleteness(config$1);
450
+ printCompleteness(completeness);
451
+ // Stop when done — or when completeness cannot be computed (non-JSON
452
+ // output formats); polling blindly would just burn time.
453
+ if (completeness.length === 0 || isComplete(completeness))
454
+ break;
455
+ if (round < POLL_DELAYS_MS.length)
456
+ await sleep(POLL_DELAYS_MS[round]);
457
+ }
458
+ }
459
+ else {
460
+ if (await download()) {
461
+ completeness = await computeCompleteness(config$1);
462
+ printCompleteness(completeness);
463
+ }
464
+ }
465
+ if (downloadFailed || (autoTranslate && completeness.length > 0 && !isComplete(completeness))) {
466
+ warn('Translations may still be processing — run `i18next-cli locize-download` in a minute.');
467
+ console.log(' (On an unsubscribed trial, AI translation needs a subscription or a configured AI/MT provider — see Project settings.)');
468
+ }
469
+ else if (completeness.length > 0) {
470
+ ok('All languages translated and downloaded.');
471
+ }
472
+ printEpilogue(config$1);
473
+ }
474
+
475
+ exports.runLocalize = runLocalize;
@@ -6,6 +6,11 @@ var ora = require('ora');
6
6
  var inquirer = require('inquirer');
7
7
  var node_path = require('node:path');
8
8
 
9
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
10
+
11
+ var ora__default = /*#__PURE__*/_interopDefault(ora);
12
+ var inquirer__default = /*#__PURE__*/_interopDefault(inquirer);
13
+
9
14
  /**
10
15
  * Resolves the locize-cli executable to use.
11
16
  *
@@ -63,7 +68,7 @@ async function resolveLocizeBin() {
63
68
  */
64
69
  async function interactiveCredentialSetup(config) {
65
70
  console.log(node_util.styleText('yellow', '\nLocize configuration is missing or invalid. Let\'s set it up!'));
66
- const answers = await inquirer.prompt([
71
+ const answers = await inquirer__default.default.prompt([
67
72
  {
68
73
  type: 'input',
69
74
  name: 'projectId',
@@ -73,7 +78,7 @@ async function interactiveCredentialSetup(config) {
73
78
  {
74
79
  type: 'password',
75
80
  name: 'apiKey',
76
- message: 'Enter your Locize API key (Project settings → API → API Keys). If your project has no languages yet, use an API key with admin role.',
81
+ message: 'Enter your Locize API key (Project settings → API → API Keys). Any write-capable key works missing languages are created automatically for new projects.',
77
82
  validate: input => !!input || 'API Key cannot be empty.',
78
83
  },
79
84
  {
@@ -87,7 +92,7 @@ async function interactiveCredentialSetup(config) {
87
92
  console.error(node_util.styleText('red', 'Project ID is required to continue.'));
88
93
  return undefined;
89
94
  }
90
- const { save } = await inquirer.prompt([{
95
+ const { save } = await inquirer__default.default.prompt([{
91
96
  type: 'confirm',
92
97
  name: 'save',
93
98
  message: 'Would you like to see how to save these credentials for future use?',
@@ -119,11 +124,42 @@ LOCIZE_API_KEY=${answers.apiKey}
119
124
  };
120
125
  }
121
126
  /**
122
- * Masks an API key for safe console output, preserving only the first
123
- * and last 3 characters while replacing everything in between.
127
+ * Error thrown by {@link runLocizeCommand} when `throwOnError` is set,
128
+ * carrying the captured output of the failed locize-cli invocation so
129
+ * orchestrating commands (e.g. `localize`) can inspect and react to it.
130
+ */
131
+ class LocizeCommandError extends Error {
132
+ stdout;
133
+ stderr;
134
+ constructor(message, output = {}) {
135
+ super(message);
136
+ this.name = 'LocizeCommandError';
137
+ this.stdout = output.stdout || '';
138
+ this.stderr = output.stderr || '';
139
+ }
140
+ }
141
+ /** Prefixed Locize token formats (PATs and the newer api-keys). */
142
+ const SECRET_PREFIXES = ['lz_pat_', 'lz_api_'];
143
+ const PREFIXED_VISIBLE_RANDOM_CHARS = 4;
144
+ const PREFIXED_VISIBLE_END_CHARS = 4;
145
+ /**
146
+ * Masks an API key / PAT for safe console output, mirroring Locize's own
147
+ * maskSecret format:
148
+ * - prefixed tokens: `lz_pat_4xK9****************************oZ1i`
149
+ * - legacy UUID keys: first and last 3 characters visible
124
150
  */
125
151
  function maskApiKey(apiKey) {
126
- if (!apiKey || apiKey.length <= 6)
152
+ if (!apiKey)
153
+ return apiKey;
154
+ const matchedPrefix = SECRET_PREFIXES.find(p => apiKey.startsWith(p));
155
+ if (matchedPrefix) {
156
+ const visibleStart = matchedPrefix.length + PREFIXED_VISIBLE_RANDOM_CHARS;
157
+ const start = apiKey.substring(0, visibleStart);
158
+ const end = apiKey.substring(apiKey.length - PREFIXED_VISIBLE_END_CHARS);
159
+ const middle = apiKey.substring(visibleStart, apiKey.length - PREFIXED_VISIBLE_END_CHARS);
160
+ return `${start}${middle.replace(/[0-9a-zA-Z]/g, '*')}${end}`;
161
+ }
162
+ if (apiKey.length <= 6)
127
163
  return apiKey;
128
164
  const first3 = apiKey.substring(0, 3);
129
165
  const last3 = apiKey.substring(apiKey.length - 3);
@@ -147,16 +183,23 @@ function maskArgs(args) {
147
183
  function buildArgs(command, config, cliOptions) {
148
184
  const { locize: locizeConfig = {}, extract } = config;
149
185
  const commandArgs = [command];
150
- const projectId = cliOptions.projectId ?? locizeConfig.projectId;
186
+ // Resolve credentials explicitly (CLI option → config → env var) and always
187
+ // forward them as flags. locize-cli would pick env vars up itself, but its
188
+ // own precedence puts a ~/.locize config file ABOVE the environment — a
189
+ // stale ~/.locize would silently redirect the run to another project.
190
+ const projectId = cliOptions.projectId ?? locizeConfig.projectId ?? process.env.LOCIZE_PROJECTID ?? process.env.LOCIZE_PID;
151
191
  if (projectId)
152
192
  commandArgs.push('--project-id', projectId);
153
- const apiKey = cliOptions.apiKey ?? locizeConfig.apiKey;
193
+ const apiKey = cliOptions.apiKey ?? locizeConfig.apiKey ?? process.env.LOCIZE_API_KEY ?? process.env.LOCIZE_KEY;
154
194
  if (apiKey)
155
195
  commandArgs.push('--api-key', apiKey);
156
- const version = cliOptions.version ?? locizeConfig.version;
196
+ const version = cliOptions.version ?? locizeConfig.version ?? process.env.LOCIZE_VERSION ?? process.env.LOCIZE_VER;
157
197
  if (version)
158
198
  commandArgs.push('--ver', version);
159
- const cdnType = cliOptions.cdnType ?? locizeConfig.cdnType;
199
+ const apiEndpoint = cliOptions.apiEndpoint ?? locizeConfig.apiEndpoint ?? process.env.LOCIZE_API_ENDPOINT;
200
+ if (apiEndpoint)
201
+ commandArgs.push('--api-endpoint', apiEndpoint);
202
+ const cdnType = cliOptions.cdnType ?? locizeConfig.cdnType ?? process.env.LOCIZE_CDN_TYPE;
160
203
  if (cdnType)
161
204
  commandArgs.push('--cdn-type', cdnType);
162
205
  // TODO: there might be more configurable locize-cli options in future
@@ -165,15 +208,34 @@ function buildArgs(command, config, cliOptions) {
165
208
  const updateValues = cliOptions.updateValues ?? locizeConfig.updateValues;
166
209
  if (updateValues)
167
210
  commandArgs.push('--update-values', 'true');
211
+ // `--reference-language-only` defaults to `true` in locize-cli, so we only
212
+ // forward it when explicitly set – passing `false` is the whole point, as it
213
+ // is the only way to opt out of the source-language-only behavior.
168
214
  const srcLngOnly = cliOptions.srcLngOnly ?? locizeConfig.sourceLanguageOnly;
169
- if (srcLngOnly)
170
- commandArgs.push('--reference-language-only', 'true');
215
+ if (srcLngOnly !== undefined) {
216
+ const referenceLanguageOnly = srcLngOnly === true || srcLngOnly === 'true';
217
+ commandArgs.push('--reference-language-only', String(referenceLanguageOnly));
218
+ }
171
219
  const compareMtime = cliOptions.compareMtime ?? locizeConfig.compareModificationTime;
172
220
  if (compareMtime)
173
221
  commandArgs.push('--compare-modification-time', 'true');
174
222
  const dryRun = cliOptions.dryRun ?? locizeConfig.dryRun;
175
223
  if (dryRun)
176
224
  commandArgs.push('--dry', 'true');
225
+ const autoTranslate = cliOptions.autoTranslate ?? locizeConfig.autoTranslate;
226
+ if (autoTranslate !== undefined) {
227
+ commandArgs.push('--auto-translate', String(autoTranslate === true || autoTranslate === 'true'));
228
+ }
229
+ const autoTranslateReview = cliOptions.autoTranslateReview ?? locizeConfig.autoTranslateReview;
230
+ if (autoTranslateReview !== undefined) {
231
+ commandArgs.push('--auto-translate-review', String(autoTranslateReview === true || autoTranslateReview === 'true'));
232
+ }
233
+ const autoTranslateLanguages = cliOptions.autoTranslateLanguages ?? locizeConfig.autoTranslateLanguages;
234
+ if (autoTranslateLanguages) {
235
+ const languages = Array.isArray(autoTranslateLanguages) ? autoTranslateLanguages.join(',') : String(autoTranslateLanguages);
236
+ if (languages)
237
+ commandArgs.push('--auto-translate-languages', languages);
238
+ }
177
239
  }
178
240
  // Derive a sensible base path for locize from the configured output.
179
241
  // If output is a string template we can strip the language placeholder.
@@ -244,8 +306,16 @@ function buildArgs(command, config, cliOptions) {
244
306
  * ```
245
307
  */
246
308
  async function runLocizeCommand(command, config, cliOptions = {}) {
309
+ const throwOnError = !!cliOptions.throwOnError;
247
310
  const resolved = await resolveLocizeBin();
248
311
  if (!resolved) {
312
+ const installHint = 'Error: `locize-cli` command not found.\n' +
313
+ 'Please install it to use the Locize integration:\n' +
314
+ ' npm install -g locize-cli\n' +
315
+ 'Or make sure npx is available so it can be fetched on demand.';
316
+ if (throwOnError) {
317
+ throw new LocizeCommandError(installHint);
318
+ }
249
319
  console.error(node_util.styleText('red', 'Error: `locize-cli` command not found.'));
250
320
  console.log(node_util.styleText('yellow', 'Please install it to use the Locize integration:'));
251
321
  console.log(node_util.styleText('cyan', ' npm install -g locize-cli'));
@@ -254,7 +324,7 @@ async function runLocizeCommand(command, config, cliOptions = {}) {
254
324
  return;
255
325
  }
256
326
  const { cmd, prefixArgs } = resolved;
257
- const spinner = ora(`Running 'locize ${command}'...\n`).start();
327
+ const spinner = ora__default.default(`Running 'locize ${command}'...\n`).start();
258
328
  let effectiveConfig = config;
259
329
  try {
260
330
  // 1. First attempt
@@ -267,6 +337,12 @@ async function runLocizeCommand(command, config, cliOptions = {}) {
267
337
  }
268
338
  catch (error) {
269
339
  const stderr = error.stderr || '';
340
+ if (throwOnError) {
341
+ // Orchestrating callers (e.g. `localize`) handle credentials and
342
+ // messaging themselves — no interactive retry, no process.exit.
343
+ spinner.fail(node_util.styleText('red', `Error executing 'locize ${command}'.`));
344
+ throw new LocizeCommandError(stderr || error.stdout || error.message, { stdout: error.stdout, stderr });
345
+ }
270
346
  if (stderr.includes('missing required argument')) {
271
347
  // 2. Auth failure, trigger interactive setup
272
348
  spinner.stop();
@@ -307,6 +383,8 @@ const runLocizeSync = (config, cliOptions) => runLocizeCommand('sync', config, c
307
383
  const runLocizeDownload = (config, cliOptions) => runLocizeCommand('download', config, cliOptions);
308
384
  const runLocizeMigrate = (config, cliOptions) => runLocizeCommand('migrate', config, cliOptions);
309
385
 
386
+ exports.LocizeCommandError = LocizeCommandError;
387
+ exports.maskApiKey = maskApiKey;
310
388
  exports.runLocizeDownload = runLocizeDownload;
311
389
  exports.runLocizeMigrate = runLocizeMigrate;
312
390
  exports.runLocizeSync = runLocizeSync;