locize-cli 12.1.1 → 12.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,9 +15,9 @@ jobs:
15
15
  # os: [ubuntu-latest, windows-latest, macOS-latest]
16
16
  os: [ubuntu-latest, windows-latest, macOS-latest]
17
17
  steps:
18
- - uses: actions/checkout@v4
18
+ - uses: actions/checkout@v6
19
19
  - name: Setup Node.js
20
- uses: actions/setup-node@v4
20
+ uses: actions/setup-node@v6
21
21
  with:
22
22
  node-version: ${{ matrix.node }}
23
23
  - run: npm install
package/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
5
5
  Project versioning adheres to [Semantic Versioning](http://semver.org/).
6
6
  Change log format is based on [Keep a Changelog](http://keepachangelog.com/).
7
7
 
8
+ ## [12.3.0](https://github.com/locize/locize-cli/compare/v12.2.0...v12.3.0) - 2026-06-10
9
+
10
+ - sync: creates missing remote languages on the fly. A project without any
11
+ languages yet is bootstrapped automatically from the local language folders
12
+ (or `--language`/`--languages`), and a language that exists locally but not
13
+ in the project is created too instead of being silently ignored — the server
14
+ decides whether the api-key may (admin keys always; any write-capable key
15
+ while the project still has no content). On rejection sync warns and
16
+ continues; `--dry` never creates languages.
17
+ - migrate: also works on a project without any languages yet (previously both
18
+ sync and migrate failed with a misleading "Project not found" error when the
19
+ project's languages file was empty).
20
+ - fix: the "wrong cdnType" hint no longer crashes (or fires) when the other
21
+ endpoint answers with a non-JSON response; the empty-project error now reads
22
+ "Project … not found — or it has no languages yet!" and carries an
23
+ `EMPTY_LANGUAGES` / `WRONG_CDN_TYPE` error code for programmatic consumers.
24
+
25
+ ## [12.2.0](https://github.com/locize/locize-cli/compare/v12.1.1...v12.2.0) - 2026-06-01
26
+
27
+ - sync: introduce `--auto-translate-languages <lng1,lng2>` option to restrict auto-translation to specific target languages
28
+
8
29
  ## [12.1.1](https://github.com/locize/locize-cli/compare/v12.1.0...v12.1.1) - 2026-04-21
9
30
 
10
31
  - sync: fix default for `--auto-translate` option
package/README.md CHANGED
@@ -178,6 +178,7 @@ locize sync --api-key my-api-key-d9de-4f55-9855-a9ef0ed44672 --project-id my-pro
178
178
  - all non reference languages will always be just locally replaced by what is published on locize, unless the *--skip-delete true* argument is used
179
179
  - if you change the values of existing keys in the reference language, it will not change them in locize (to change the existing values you have to change it directly in locize or use the *--update-values true* argument)
180
180
  - if you update existing keys (with *--update-values true*) the automatic translation is not triggered, unless the *--auto-translate true* argument us used
181
+ - if you want to restrict the automatic translation to specific target languages only, you can use the *--auto-translate-languages de,fr* argument (together with *--auto-translate true*); if omitted, all languages are auto-translated
181
182
  - **if you want to take into account all languages instead of the reference language only while comparing the namespace content between local and remote, you can use the command argument *--reference-language-only false***
182
183
  - **if you want to take into account the modification time while comparing the namespace content between local and remote, you can use the command argument *--compare-modification-time true***
183
184
 
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ var colors = require('colors');
4
+ var request = require('./request.js');
5
+
6
+ /**
7
+ * Adds a language to the project via POST /language/{projectId}/{language}.
8
+ *
9
+ * Idempotent for bootstrap flows: an "already exists" answer counts as
10
+ * success — the API responds 400 (ValidationError "Language already exists!"),
11
+ * and 412 is kept for backwards compatibility with older deployments.
12
+ *
13
+ * Other failures throw an Error carrying `status` (e.g. 401 when the key
14
+ * may not create languages) so callers can give role-specific guidance.
15
+ */
16
+ const addLanguage = async (opt, l) => {
17
+ const url = opt.apiEndpoint + '/language/' + opt.projectId + '/' + l;
18
+ try {
19
+ const { res, obj } = await request(url, {
20
+ method: 'post',
21
+ headers: {
22
+ Authorization: opt.apiKey
23
+ }
24
+ });
25
+ if (res.status === 400 && ((obj && (obj.errorMessage || obj.message)) || '').indexOf('already exists') > -1) {
26
+ console.log(colors.yellow(`language ${l} already exists...`));
27
+ return
28
+ }
29
+ if (res.status >= 300 && res.status !== 412) {
30
+ const serverMessage = obj && (obj.errorMessage || obj.message);
31
+ const err = new Error(serverMessage ? `${res.statusText} (${res.status}) | ${serverMessage}` : `${res.statusText} (${res.status})`);
32
+ err.status = res.status;
33
+ throw err
34
+ }
35
+ console.log(colors.green(`added language ${l}...`));
36
+ } catch (err) {
37
+ console.log(colors.red(`failed to add language ${l}...`));
38
+ throw err
39
+ }
40
+ };
41
+
42
+ module.exports = addLanguage;
package/dist/cjs/cli.js CHANGED
@@ -50,7 +50,7 @@ const program = new commander.Command();
50
50
 
51
51
  program
52
52
  .description('The official locize CLI.')
53
- .version('12.1.1'); // This string is replaced with the actual version at build time by rollup
53
+ .version('12.3.0'); // This string is replaced with the actual version at build time by rollup
54
54
  // .option('-a, --api-endpoint <url>', `Specify the api-endpoint url that should be used (default: ${defaultApiEndpoint})`)
55
55
  // .option('-C, --config-path <configPath>', `Specify the path to the optional locize config file (default: ${configInWorkingDirectory} or ${configInHome})`);
56
56
 
@@ -419,6 +419,7 @@ program
419
419
  .option('-u, --update-values <true|false>', 'This will update values of existing translations. (default: false)', 'false')
420
420
  .option('--auto-translate <true|false>', 'This will trigger auto-translation of updated translations.')
421
421
  .option('--auto-translate-review <true|false>', 'When auto-translating, route results through the review workflow for languages that have review enabled. (default: false)', 'false')
422
+ .option('--auto-translate-languages <lng1,lng2>', 'Restrict auto-translation to these target languages only (comma separated). Only effective together with --auto-translate and when updating the reference language. If omitted, all languages are auto-translated.')
422
423
  .option('-S, --skip-delete <true|false>', 'This will skip the removal of keys on locize. (default: false)', 'false')
423
424
  .option('-D, --delete-remote-namespace <true|false>', 'This will delete a complete namespace on locize, if a local file in reference language was deleted. (default: false)', 'false')
424
425
  .option('-m, --path-mask <mask>', 'This will define the folder and file structure; do not add a file extension (default: {{language}}/{{namespace}})', `{{language}}${path.sep}{{namespace}}`)
@@ -476,6 +477,7 @@ program
476
477
  const updateValues = options.updateValues === 'true';
477
478
  const autoTranslate = options.autoTranslate === 'true' ? true : options.autoTranslate === 'false' ? false : undefined;
478
479
  const autoTranslateReview = options.autoTranslateReview === 'true';
480
+ const autoTranslateLanguages = options.autoTranslateLanguages;
479
481
  const skipDelete = options.skipDelete === 'true';
480
482
  const deleteRemoteNamespace = options.deleteRemoteNamespace === 'true';
481
483
  const languageFolderPrefix = options.languageFolderPrefix || '';
@@ -500,6 +502,7 @@ program
500
502
  updateValues,
501
503
  autoTranslate,
502
504
  autoTranslateReview,
505
+ autoTranslateLanguages: autoTranslateLanguages && autoTranslateLanguages.split(',').map((l) => l.trim()).filter(Boolean),
503
506
  skipDelete,
504
507
  deleteRemoteNamespace,
505
508
  languageFolderPrefix,
@@ -21,13 +21,26 @@ const getRemoteLanguages = async (opt) => {
21
21
  if (res.status >= 300) throw new Error(res.statusText + ' (' + res.status + ')')
22
22
 
23
23
  if (Object.keys(obj).length === 0) {
24
- let errMsg = 'Project with id "' + opt.projectId + '" not found!';
24
+ // An empty object is ambiguous: unknown project, a project without
25
+ // languages, or a freshly created project whose languages file has not
26
+ // been generated yet.
27
+ let errMsg = 'Project with id "' + opt.projectId + '" not found — or it has no languages yet!';
25
28
  const otherEndpoint = getOtherApiEndpoint(opt.apiEndpoint);
26
- const { res: res2, obj: obj2 } = await request(otherEndpoint + '/languages/' + opt.projectId + '?ts=' + Date.now() + (opt.cdnType === 'standard' ? '' : '&cache=no'), { method: 'get' });
27
- if (res2.status === 200 && Object.keys(obj2).length > 0) {
28
- errMsg += ` It seems you're using the wrong cdnType. Your Locize project is configured to use "${opt.cdnType === 'standard' ? 'pro' : 'standard'}" but here you've configured "${opt.cdnType}".`;
29
+ if (otherEndpoint) {
30
+ const { res: res2, obj: obj2 } = await request(otherEndpoint + '/languages/' + opt.projectId + '?ts=' + Date.now() + (opt.cdnType === 'standard' ? '' : '&cache=no'), { method: 'get' });
31
+ // obj2 is undefined for non-JSON responses (e.g. an HTML 404 page)
32
+ // only a real, non-empty JSON object indicates the other cdnType.
33
+ if (res2.status === 200 && obj2 && typeof obj2 === 'object' && Object.keys(obj2).length > 0) {
34
+ errMsg += ` It seems you're using the wrong cdnType. Your Locize project is configured to use "${opt.cdnType === 'standard' ? 'pro' : 'standard'}" but here you've configured "${opt.cdnType}".`;
35
+ const cdnErr = new Error(errMsg);
36
+ cdnErr.code = 'WRONG_CDN_TYPE';
37
+ throw cdnErr
38
+ }
29
39
  }
30
- throw new Error(errMsg)
40
+ const err = new Error(errMsg);
41
+ // lets callers (sync/migrate) bootstrap the languages instead of failing
42
+ err.code = 'EMPTY_LANGUAGES';
43
+ throw err
31
44
  }
32
45
 
33
46
  const lngs = Object.keys(obj);
@@ -6,6 +6,7 @@ var flatten = require('flat');
6
6
  var colors = require('colors');
7
7
  var request = require('./request.js');
8
8
  var getRemoteLanguages = require('./getRemoteLanguages.js');
9
+ var addLanguage = require('./addLanguage.js');
9
10
  var os = require('node:os');
10
11
  var mapLimit = require('./mapLimit.js');
11
12
  var download = require('./download.js');
@@ -149,23 +150,6 @@ const upload = async (opt, nss) => {
149
150
  await mapLimit(nssNonRefLng, concurrency, async (ns) => transfer(opt, ns));
150
151
  };
151
152
 
152
- const addLanguage = async (opt, l) => {
153
- const url = opt.apiEndpoint + '/language/' + opt.projectId + '/' + l;
154
- try {
155
- const { res } = await request(url, {
156
- method: 'post',
157
- headers: {
158
- Authorization: opt.apiKey
159
- }
160
- });
161
- if (res.status >= 300 && res.status !== 412) throw new Error(res.statusText + ' (' + res.status + ')')
162
- console.log(colors.green(`added language ${l}...`));
163
- } catch (err) {
164
- console.log(colors.red(`failed to add language ${l}...`));
165
- throw err
166
- }
167
- };
168
-
169
153
  const downloadAfterMigrate = async (opt) => {
170
154
  console.log(colors.yellow('downloading translations after migration...'));
171
155
  await new Promise((resolve) => setTimeout(resolve, 15000));
@@ -224,8 +208,14 @@ const migrate = async (opt) => {
224
208
  try {
225
209
  remoteLanguages = await getRemoteLanguages(opt);
226
210
  } catch (err) {
227
- console.error(colors.red(err.stack));
228
- process.exit(1);
211
+ // A project without any languages yet is fine for migrate — every local
212
+ // language is created below via addLanguage before the upload.
213
+ if (err.code === 'EMPTY_LANGUAGES' && opt.apiKey) {
214
+ remoteLanguages = [];
215
+ } else {
216
+ console.error(colors.red(err.stack));
217
+ process.exit(1);
218
+ }
229
219
  }
230
220
  const localLanguages = [];
231
221
  nss.forEach((n) => {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "locize-cli",
3
- "version": "12.1.1",
3
+ "version": "12.3.0",
4
4
  "type": "commonjs"
5
5
  }
@@ -39,7 +39,7 @@ async function request (url, options) {
39
39
  }
40
40
 
41
41
  options.headers = options.headers || {};
42
- options.headers['User-Agent'] = `locize-cli/v12.1.1 (node/${process.version}; ${process.platform} ${process.arch})`; // This string is replaced with the actual version at build time by rollup
42
+ options.headers['User-Agent'] = `locize-cli/v12.3.0 (node/${process.version}; ${process.platform} ${process.arch})`; // This string is replaced with the actual version at build time by rollup
43
43
  options.headers['X-User-Agent'] = options.headers['User-Agent'];
44
44
  if (options.body || options.method !== 'get') options.headers['Content-Type'] = 'application/json';
45
45
  if (options.body) {
package/dist/cjs/sync.js CHANGED
@@ -19,6 +19,7 @@ var getProjectStats = require('./getProjectStats.js');
19
19
  var xcstrings = require('locize-xcstrings');
20
20
  var getBranches = require('./getBranches.js');
21
21
  var isValidUuid = require('./isValidUuid.js');
22
+ var addLanguage = require('./addLanguage.js');
22
23
  var os = require('node:os');
23
24
  var lngs = require('./lngs.js');
24
25
 
@@ -435,6 +436,9 @@ async function update (opt, lng, ns, shouldOmit = false) {
435
436
  if (opt.autoTranslateReview && lng === opt.referenceLanguage) {
436
437
  queryParams.append('autotranslatereview', 'true');
437
438
  }
439
+ if (opt.autoTranslateLanguages && opt.autoTranslateLanguages.length > 0 && lng === opt.referenceLanguage) {
440
+ queryParams.append('autotranslatelanguages', opt.autoTranslateLanguages.join(','));
441
+ }
438
442
  if (so) {
439
443
  queryParams.append('omitstatsgeneration', 'true');
440
444
  }
@@ -667,9 +671,101 @@ const checkForMissingLocalNamespaces = (downloads, localNamespaces, opt) => {
667
671
  return localMissingNamespaces
668
672
  };
669
673
 
674
+ /**
675
+ * Discovers which languages the local files intend, independent of the remote
676
+ * state: explicit --language/--languages win; otherwise the language folders
677
+ * under opt.path (only when the path mask starts with the language segment —
678
+ * the default `{{language}}/{{namespace}}` layout).
679
+ */
680
+ const discoverLocalLanguages = (opt) => {
681
+ if (opt.languages && opt.languages.length > 0) return [...opt.languages]
682
+ if (opt.language) return [opt.language]
683
+ const languageToken = `${opt.pathMaskInterpolationPrefix || '{{'}language${opt.pathMaskInterpolationSuffix || '}}'}`;
684
+ const pathMask = opt.pathMask || `${languageToken}${path.sep}{{namespace}}`;
685
+ if (!pathMask.startsWith(`${opt.languageFolderPrefix || ''}${languageToken}`)) return []
686
+ try {
687
+ return fs.readdirSync(opt.path)
688
+ .filter((file) => fs.statSync(path.join(opt.path, file)).isDirectory())
689
+ .map((dir) => (opt.languageFolderPrefix ? dir.replace(opt.languageFolderPrefix, '') : dir))
690
+ } catch (err) {
691
+ return []
692
+ }
693
+ };
694
+
695
+ /**
696
+ * Bootstraps the remote languages of a project that has none yet by creating
697
+ * the locally intended languages (the server authorizes: admin keys always;
698
+ * any write-capable key while the project is still empty), then waits for the
699
+ * asynchronously regenerated languages file.
700
+ */
701
+ const bootstrapRemoteLanguages = async (opt) => {
702
+ const noLanguagesHelp = `Project with id "${opt.projectId}" has no languages yet`;
703
+ const localLanguages = discoverLocalLanguages(opt);
704
+ if (localLanguages.length === 0) {
705
+ throw new Error(`${noLanguagesHelp} and no local languages were found to create — add languages in the project settings (or push initial content via "locize migrate"), then retry.`)
706
+ }
707
+
708
+ console.log(colors.yellow(`project has no languages yet — creating ${localLanguages.join(', ')}...`));
709
+ try {
710
+ for (const l of localLanguages) {
711
+ await addLanguage(opt, l);
712
+ }
713
+ } catch (err) {
714
+ if (err.status === 401) {
715
+ throw new Error(`${noLanguagesHelp} and this api-key may not create them — use an admin-scoped api-key (or any write-capable key while the project is still empty), or add the languages in the project settings, then retry.`)
716
+ }
717
+ throw err
718
+ }
719
+
720
+ // the languages file is regenerated asynchronously — retry a few times
721
+ let lastError;
722
+ for (let i = 0; i < 3; i++) {
723
+ await new Promise((resolve) => setTimeout(resolve, 5000));
724
+ try {
725
+ return await getRemoteLanguages(opt)
726
+ } catch (err) {
727
+ lastError = err;
728
+ if (err.code !== 'EMPTY_LANGUAGES') throw err
729
+ }
730
+ }
731
+ throw lastError
732
+ };
733
+
670
734
  async function continueToSync (opt) {
671
735
  console.log(colors.grey('checking remote (locize)...'));
672
- const remoteLanguages = await getRemoteLanguages(opt);
736
+ let remoteLanguages;
737
+ try {
738
+ remoteLanguages = await getRemoteLanguages(opt);
739
+ } catch (err) {
740
+ // A project without languages is bootstrapped on the fly (unless this is
741
+ // a dry run or we have no key to create them with).
742
+ if (err.code !== 'EMPTY_LANGUAGES' || !opt.apiKey || opt.dry) throw err
743
+ remoteLanguages = await bootstrapRemoteLanguages(opt);
744
+ }
745
+
746
+ // Languages that exist locally but not remotely are created too — the
747
+ // server decides whether the key may (admin always; write-capable keys
748
+ // while the project is empty). On rejection we warn instead of silently
749
+ // ignoring the local files (the pre-existing behavior).
750
+ const missingRemotely = discoverLocalLanguages(opt).filter((l) => remoteLanguages.indexOf(l) < 0);
751
+ if (missingRemotely.length > 0) {
752
+ if (opt.apiKey && !opt.dry) {
753
+ const created = [];
754
+ for (const l of missingRemotely) {
755
+ try {
756
+ await addLanguage(opt, l);
757
+ created.push(l);
758
+ } catch (err) {
759
+ console.log(colors.yellow(`language "${l}" exists locally but not in your locize project and this api-key may not create it (${err.message}) — add it in the project settings or use an admin api-key.`));
760
+ }
761
+ }
762
+ // the regenerated languages file lags behind — continue with the
763
+ // created languages included so this run already syncs them
764
+ created.forEach((l) => remoteLanguages.push(l));
765
+ } else {
766
+ missingRemotely.forEach((l) => console.log(colors.yellow(`language "${l}" exists locally but not in your locize project${opt.dry ? ' (dry run: not creating it)' : ' (no api-key: cannot create it)'}.`)));
767
+ }
768
+ }
673
769
 
674
770
  if (opt.referenceLanguageOnly && opt.language && opt.referenceLanguage !== opt.language) {
675
771
  opt.referenceLanguage = opt.language;
@@ -0,0 +1,40 @@
1
+ import colors from 'colors';
2
+ import request from './request.js';
3
+
4
+ /**
5
+ * Adds a language to the project via POST /language/{projectId}/{language}.
6
+ *
7
+ * Idempotent for bootstrap flows: an "already exists" answer counts as
8
+ * success — the API responds 400 (ValidationError "Language already exists!"),
9
+ * and 412 is kept for backwards compatibility with older deployments.
10
+ *
11
+ * Other failures throw an Error carrying `status` (e.g. 401 when the key
12
+ * may not create languages) so callers can give role-specific guidance.
13
+ */
14
+ const addLanguage = async (opt, l) => {
15
+ const url = opt.apiEndpoint + '/language/' + opt.projectId + '/' + l;
16
+ try {
17
+ const { res, obj } = await request(url, {
18
+ method: 'post',
19
+ headers: {
20
+ Authorization: opt.apiKey
21
+ }
22
+ });
23
+ if (res.status === 400 && ((obj && (obj.errorMessage || obj.message)) || '').indexOf('already exists') > -1) {
24
+ console.log(colors.yellow(`language ${l} already exists...`));
25
+ return
26
+ }
27
+ if (res.status >= 300 && res.status !== 412) {
28
+ const serverMessage = obj && (obj.errorMessage || obj.message);
29
+ const err = new Error(serverMessage ? `${res.statusText} (${res.status}) | ${serverMessage}` : `${res.statusText} (${res.status})`);
30
+ err.status = res.status;
31
+ throw err
32
+ }
33
+ console.log(colors.green(`added language ${l}...`));
34
+ } catch (err) {
35
+ console.log(colors.red(`failed to add language ${l}...`));
36
+ throw err
37
+ }
38
+ };
39
+
40
+ export { addLanguage as default };
package/dist/esm/cli.js CHANGED
@@ -48,7 +48,7 @@ const program = new Command();
48
48
 
49
49
  program
50
50
  .description('The official locize CLI.')
51
- .version('12.1.1'); // This string is replaced with the actual version at build time by rollup
51
+ .version('12.3.0'); // This string is replaced with the actual version at build time by rollup
52
52
  // .option('-a, --api-endpoint <url>', `Specify the api-endpoint url that should be used (default: ${defaultApiEndpoint})`)
53
53
  // .option('-C, --config-path <configPath>', `Specify the path to the optional locize config file (default: ${configInWorkingDirectory} or ${configInHome})`);
54
54
 
@@ -417,6 +417,7 @@ program
417
417
  .option('-u, --update-values <true|false>', 'This will update values of existing translations. (default: false)', 'false')
418
418
  .option('--auto-translate <true|false>', 'This will trigger auto-translation of updated translations.')
419
419
  .option('--auto-translate-review <true|false>', 'When auto-translating, route results through the review workflow for languages that have review enabled. (default: false)', 'false')
420
+ .option('--auto-translate-languages <lng1,lng2>', 'Restrict auto-translation to these target languages only (comma separated). Only effective together with --auto-translate and when updating the reference language. If omitted, all languages are auto-translated.')
420
421
  .option('-S, --skip-delete <true|false>', 'This will skip the removal of keys on locize. (default: false)', 'false')
421
422
  .option('-D, --delete-remote-namespace <true|false>', 'This will delete a complete namespace on locize, if a local file in reference language was deleted. (default: false)', 'false')
422
423
  .option('-m, --path-mask <mask>', 'This will define the folder and file structure; do not add a file extension (default: {{language}}/{{namespace}})', `{{language}}${path.sep}{{namespace}}`)
@@ -474,6 +475,7 @@ program
474
475
  const updateValues = options.updateValues === 'true';
475
476
  const autoTranslate = options.autoTranslate === 'true' ? true : options.autoTranslate === 'false' ? false : undefined;
476
477
  const autoTranslateReview = options.autoTranslateReview === 'true';
478
+ const autoTranslateLanguages = options.autoTranslateLanguages;
477
479
  const skipDelete = options.skipDelete === 'true';
478
480
  const deleteRemoteNamespace = options.deleteRemoteNamespace === 'true';
479
481
  const languageFolderPrefix = options.languageFolderPrefix || '';
@@ -498,6 +500,7 @@ program
498
500
  updateValues,
499
501
  autoTranslate,
500
502
  autoTranslateReview,
503
+ autoTranslateLanguages: autoTranslateLanguages && autoTranslateLanguages.split(',').map((l) => l.trim()).filter(Boolean),
501
504
  skipDelete,
502
505
  deleteRemoteNamespace,
503
506
  languageFolderPrefix,
@@ -19,13 +19,26 @@ const getRemoteLanguages = async (opt) => {
19
19
  if (res.status >= 300) throw new Error(res.statusText + ' (' + res.status + ')')
20
20
 
21
21
  if (Object.keys(obj).length === 0) {
22
- let errMsg = 'Project with id "' + opt.projectId + '" not found!';
22
+ // An empty object is ambiguous: unknown project, a project without
23
+ // languages, or a freshly created project whose languages file has not
24
+ // been generated yet.
25
+ let errMsg = 'Project with id "' + opt.projectId + '" not found — or it has no languages yet!';
23
26
  const otherEndpoint = getOtherApiEndpoint(opt.apiEndpoint);
24
- const { res: res2, obj: obj2 } = await request(otherEndpoint + '/languages/' + opt.projectId + '?ts=' + Date.now() + (opt.cdnType === 'standard' ? '' : '&cache=no'), { method: 'get' });
25
- if (res2.status === 200 && Object.keys(obj2).length > 0) {
26
- errMsg += ` It seems you're using the wrong cdnType. Your Locize project is configured to use "${opt.cdnType === 'standard' ? 'pro' : 'standard'}" but here you've configured "${opt.cdnType}".`;
27
+ if (otherEndpoint) {
28
+ const { res: res2, obj: obj2 } = await request(otherEndpoint + '/languages/' + opt.projectId + '?ts=' + Date.now() + (opt.cdnType === 'standard' ? '' : '&cache=no'), { method: 'get' });
29
+ // obj2 is undefined for non-JSON responses (e.g. an HTML 404 page)
30
+ // only a real, non-empty JSON object indicates the other cdnType.
31
+ if (res2.status === 200 && obj2 && typeof obj2 === 'object' && Object.keys(obj2).length > 0) {
32
+ errMsg += ` It seems you're using the wrong cdnType. Your Locize project is configured to use "${opt.cdnType === 'standard' ? 'pro' : 'standard'}" but here you've configured "${opt.cdnType}".`;
33
+ const cdnErr = new Error(errMsg);
34
+ cdnErr.code = 'WRONG_CDN_TYPE';
35
+ throw cdnErr
36
+ }
27
37
  }
28
- throw new Error(errMsg)
38
+ const err = new Error(errMsg);
39
+ // lets callers (sync/migrate) bootstrap the languages instead of failing
40
+ err.code = 'EMPTY_LANGUAGES';
41
+ throw err
29
42
  }
30
43
 
31
44
  const lngs = Object.keys(obj);
@@ -4,6 +4,7 @@ import flatten from 'flat';
4
4
  import colors from 'colors';
5
5
  import request from './request.js';
6
6
  import getRemoteLanguages from './getRemoteLanguages.js';
7
+ import addLanguage from './addLanguage.js';
7
8
  import os from 'node:os';
8
9
  import mapLimit from './mapLimit.js';
9
10
  import download from './download.js';
@@ -147,23 +148,6 @@ const upload = async (opt, nss) => {
147
148
  await mapLimit(nssNonRefLng, concurrency, async (ns) => transfer(opt, ns));
148
149
  };
149
150
 
150
- const addLanguage = async (opt, l) => {
151
- const url = opt.apiEndpoint + '/language/' + opt.projectId + '/' + l;
152
- try {
153
- const { res } = await request(url, {
154
- method: 'post',
155
- headers: {
156
- Authorization: opt.apiKey
157
- }
158
- });
159
- if (res.status >= 300 && res.status !== 412) throw new Error(res.statusText + ' (' + res.status + ')')
160
- console.log(colors.green(`added language ${l}...`));
161
- } catch (err) {
162
- console.log(colors.red(`failed to add language ${l}...`));
163
- throw err
164
- }
165
- };
166
-
167
151
  const downloadAfterMigrate = async (opt) => {
168
152
  console.log(colors.yellow('downloading translations after migration...'));
169
153
  await new Promise((resolve) => setTimeout(resolve, 15000));
@@ -222,8 +206,14 @@ const migrate = async (opt) => {
222
206
  try {
223
207
  remoteLanguages = await getRemoteLanguages(opt);
224
208
  } catch (err) {
225
- console.error(colors.red(err.stack));
226
- process.exit(1);
209
+ // A project without any languages yet is fine for migrate — every local
210
+ // language is created below via addLanguage before the upload.
211
+ if (err.code === 'EMPTY_LANGUAGES' && opt.apiKey) {
212
+ remoteLanguages = [];
213
+ } else {
214
+ console.error(colors.red(err.stack));
215
+ process.exit(1);
216
+ }
227
217
  }
228
218
  const localLanguages = [];
229
219
  nss.forEach((n) => {
@@ -37,7 +37,7 @@ async function request (url, options) {
37
37
  }
38
38
 
39
39
  options.headers = options.headers || {};
40
- options.headers['User-Agent'] = `locize-cli/v12.1.1 (node/${process.version}; ${process.platform} ${process.arch})`; // This string is replaced with the actual version at build time by rollup
40
+ options.headers['User-Agent'] = `locize-cli/v12.3.0 (node/${process.version}; ${process.platform} ${process.arch})`; // This string is replaced with the actual version at build time by rollup
41
41
  options.headers['X-User-Agent'] = options.headers['User-Agent'];
42
42
  if (options.body || options.method !== 'get') options.headers['Content-Type'] = 'application/json';
43
43
  if (options.body) {
package/dist/esm/sync.js CHANGED
@@ -17,6 +17,7 @@ import getProjectStats from './getProjectStats.js';
17
17
  import xcstrings from 'locize-xcstrings';
18
18
  import getBranches from './getBranches.js';
19
19
  import isValidUuid from './isValidUuid.js';
20
+ import addLanguage from './addLanguage.js';
20
21
  import os from 'node:os';
21
22
  import lngCodes from './lngs.js';
22
23
 
@@ -433,6 +434,9 @@ async function update (opt, lng, ns, shouldOmit = false) {
433
434
  if (opt.autoTranslateReview && lng === opt.referenceLanguage) {
434
435
  queryParams.append('autotranslatereview', 'true');
435
436
  }
437
+ if (opt.autoTranslateLanguages && opt.autoTranslateLanguages.length > 0 && lng === opt.referenceLanguage) {
438
+ queryParams.append('autotranslatelanguages', opt.autoTranslateLanguages.join(','));
439
+ }
436
440
  if (so) {
437
441
  queryParams.append('omitstatsgeneration', 'true');
438
442
  }
@@ -665,9 +669,101 @@ const checkForMissingLocalNamespaces = (downloads, localNamespaces, opt) => {
665
669
  return localMissingNamespaces
666
670
  };
667
671
 
672
+ /**
673
+ * Discovers which languages the local files intend, independent of the remote
674
+ * state: explicit --language/--languages win; otherwise the language folders
675
+ * under opt.path (only when the path mask starts with the language segment —
676
+ * the default `{{language}}/{{namespace}}` layout).
677
+ */
678
+ const discoverLocalLanguages = (opt) => {
679
+ if (opt.languages && opt.languages.length > 0) return [...opt.languages]
680
+ if (opt.language) return [opt.language]
681
+ const languageToken = `${opt.pathMaskInterpolationPrefix || '{{'}language${opt.pathMaskInterpolationSuffix || '}}'}`;
682
+ const pathMask = opt.pathMask || `${languageToken}${path.sep}{{namespace}}`;
683
+ if (!pathMask.startsWith(`${opt.languageFolderPrefix || ''}${languageToken}`)) return []
684
+ try {
685
+ return fs.readdirSync(opt.path)
686
+ .filter((file) => fs.statSync(path.join(opt.path, file)).isDirectory())
687
+ .map((dir) => (opt.languageFolderPrefix ? dir.replace(opt.languageFolderPrefix, '') : dir))
688
+ } catch (err) {
689
+ return []
690
+ }
691
+ };
692
+
693
+ /**
694
+ * Bootstraps the remote languages of a project that has none yet by creating
695
+ * the locally intended languages (the server authorizes: admin keys always;
696
+ * any write-capable key while the project is still empty), then waits for the
697
+ * asynchronously regenerated languages file.
698
+ */
699
+ const bootstrapRemoteLanguages = async (opt) => {
700
+ const noLanguagesHelp = `Project with id "${opt.projectId}" has no languages yet`;
701
+ const localLanguages = discoverLocalLanguages(opt);
702
+ if (localLanguages.length === 0) {
703
+ throw new Error(`${noLanguagesHelp} and no local languages were found to create — add languages in the project settings (or push initial content via "locize migrate"), then retry.`)
704
+ }
705
+
706
+ console.log(colors.yellow(`project has no languages yet — creating ${localLanguages.join(', ')}...`));
707
+ try {
708
+ for (const l of localLanguages) {
709
+ await addLanguage(opt, l);
710
+ }
711
+ } catch (err) {
712
+ if (err.status === 401) {
713
+ throw new Error(`${noLanguagesHelp} and this api-key may not create them — use an admin-scoped api-key (or any write-capable key while the project is still empty), or add the languages in the project settings, then retry.`)
714
+ }
715
+ throw err
716
+ }
717
+
718
+ // the languages file is regenerated asynchronously — retry a few times
719
+ let lastError;
720
+ for (let i = 0; i < 3; i++) {
721
+ await new Promise((resolve) => setTimeout(resolve, 5000));
722
+ try {
723
+ return await getRemoteLanguages(opt)
724
+ } catch (err) {
725
+ lastError = err;
726
+ if (err.code !== 'EMPTY_LANGUAGES') throw err
727
+ }
728
+ }
729
+ throw lastError
730
+ };
731
+
668
732
  async function continueToSync (opt) {
669
733
  console.log(colors.grey('checking remote (locize)...'));
670
- const remoteLanguages = await getRemoteLanguages(opt);
734
+ let remoteLanguages;
735
+ try {
736
+ remoteLanguages = await getRemoteLanguages(opt);
737
+ } catch (err) {
738
+ // A project without languages is bootstrapped on the fly (unless this is
739
+ // a dry run or we have no key to create them with).
740
+ if (err.code !== 'EMPTY_LANGUAGES' || !opt.apiKey || opt.dry) throw err
741
+ remoteLanguages = await bootstrapRemoteLanguages(opt);
742
+ }
743
+
744
+ // Languages that exist locally but not remotely are created too — the
745
+ // server decides whether the key may (admin always; write-capable keys
746
+ // while the project is empty). On rejection we warn instead of silently
747
+ // ignoring the local files (the pre-existing behavior).
748
+ const missingRemotely = discoverLocalLanguages(opt).filter((l) => remoteLanguages.indexOf(l) < 0);
749
+ if (missingRemotely.length > 0) {
750
+ if (opt.apiKey && !opt.dry) {
751
+ const created = [];
752
+ for (const l of missingRemotely) {
753
+ try {
754
+ await addLanguage(opt, l);
755
+ created.push(l);
756
+ } catch (err) {
757
+ console.log(colors.yellow(`language "${l}" exists locally but not in your locize project and this api-key may not create it (${err.message}) — add it in the project settings or use an admin api-key.`));
758
+ }
759
+ }
760
+ // the regenerated languages file lags behind — continue with the
761
+ // created languages included so this run already syncs them
762
+ created.forEach((l) => remoteLanguages.push(l));
763
+ } else {
764
+ missingRemotely.forEach((l) => console.log(colors.yellow(`language "${l}" exists locally but not in your locize project${opt.dry ? ' (dry run: not creating it)' : ' (no api-key: cannot create it)'}.`)));
765
+ }
766
+ }
671
767
 
672
768
  if (opt.referenceLanguageOnly && opt.language && opt.referenceLanguage !== opt.language) {
673
769
  opt.referenceLanguage = opt.language;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locize-cli",
3
- "version": "12.1.1",
3
+ "version": "12.3.0",
4
4
  "description": "locize cli to import locales",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",