locize-cli 12.2.0 → 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,23 @@ 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
+
8
25
  ## [12.2.0](https://github.com/locize/locize-cli/compare/v12.1.1...v12.2.0) - 2026-06-01
9
26
 
10
27
  - sync: introduce `--auto-translate-languages <lng1,lng2>` option to restrict auto-translation to specific target languages
@@ -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.2.0'); // 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
 
@@ -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.2.0",
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.2.0 (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
 
@@ -670,9 +671,101 @@ const checkForMissingLocalNamespaces = (downloads, localNamespaces, opt) => {
670
671
  return localMissingNamespaces
671
672
  };
672
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
+
673
734
  async function continueToSync (opt) {
674
735
  console.log(colors.grey('checking remote (locize)...'));
675
- 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
+ }
676
769
 
677
770
  if (opt.referenceLanguageOnly && opt.language && opt.referenceLanguage !== opt.language) {
678
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.2.0'); // 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
 
@@ -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.2.0 (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
 
@@ -668,9 +669,101 @@ const checkForMissingLocalNamespaces = (downloads, localNamespaces, opt) => {
668
669
  return localMissingNamespaces
669
670
  };
670
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
+
671
732
  async function continueToSync (opt) {
672
733
  console.log(colors.grey('checking remote (locize)...'));
673
- 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
+ }
674
767
 
675
768
  if (opt.referenceLanguageOnly && opt.language && opt.referenceLanguage !== opt.language) {
676
769
  opt.referenceLanguage = opt.language;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locize-cli",
3
- "version": "12.2.0",
3
+ "version": "12.3.0",
4
4
  "description": "locize cli to import locales",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",