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