locize-cli 12.2.0 → 12.3.1
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.
- package/.github/workflows/node.yml +2 -2
- package/CHANGELOG.md +24 -0
- package/dist/cjs/addLanguage.js +42 -0
- package/dist/cjs/cli.js +1 -1
- package/dist/cjs/getRemoteLanguages.js +18 -5
- package/dist/cjs/migrate.js +9 -19
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/request.js +15 -6
- package/dist/cjs/sync.js +94 -1
- package/dist/esm/addLanguage.js +40 -0
- package/dist/esm/cli.js +1 -1
- package/dist/esm/getRemoteLanguages.js +18 -5
- package/dist/esm/migrate.js +9 -19
- package/dist/esm/request.js +15 -6
- package/dist/esm/sync.js +94 -1
- package/package.json +1 -1
|
@@ -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@
|
|
18
|
+
- uses: actions/checkout@v6
|
|
19
19
|
- name: Setup Node.js
|
|
20
|
-
uses: actions/setup-node@
|
|
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,30 @@ 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.1](https://github.com/locize/locize-cli/compare/v12.3.0...v12.3.1) - 2026-06-18
|
|
9
|
+
|
|
10
|
+
- request: retry transient network failures with exponential backoff and jitter
|
|
11
|
+
instead of a fixed 5s delay. This spreads retries so that bursts of
|
|
12
|
+
concurrent CLI runs (e.g. in CI) no longer retry in lockstep and hit the API
|
|
13
|
+
at the same instant.
|
|
14
|
+
|
|
15
|
+
## [12.3.0](https://github.com/locize/locize-cli/compare/v12.2.0...v12.3.0) - 2026-06-10
|
|
16
|
+
|
|
17
|
+
- sync: creates missing remote languages on the fly. A project without any
|
|
18
|
+
languages yet is bootstrapped automatically from the local language folders
|
|
19
|
+
(or `--language`/`--languages`), and a language that exists locally but not
|
|
20
|
+
in the project is created too instead of being silently ignored — the server
|
|
21
|
+
decides whether the api-key may (admin keys always; any write-capable key
|
|
22
|
+
while the project still has no content). On rejection sync warns and
|
|
23
|
+
continues; `--dry` never creates languages.
|
|
24
|
+
- migrate: also works on a project without any languages yet (previously both
|
|
25
|
+
sync and migrate failed with a misleading "Project not found" error when the
|
|
26
|
+
project's languages file was empty).
|
|
27
|
+
- fix: the "wrong cdnType" hint no longer crashes (or fires) when the other
|
|
28
|
+
endpoint answers with a non-JSON response; the empty-project error now reads
|
|
29
|
+
"Project … not found — or it has no languages yet!" and carries an
|
|
30
|
+
`EMPTY_LANGUAGES` / `WRONG_CDN_TYPE` error code for programmatic consumers.
|
|
31
|
+
|
|
8
32
|
## [12.2.0](https://github.com/locize/locize-cli/compare/v12.1.1...v12.2.0) - 2026-06-01
|
|
9
33
|
|
|
10
34
|
- 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.
|
|
53
|
+
.version('12.3.1'); // 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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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);
|
package/dist/cjs/migrate.js
CHANGED
|
@@ -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
|
-
|
|
228
|
-
|
|
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) => {
|
package/dist/cjs/package.json
CHANGED
package/dist/cjs/request.js
CHANGED
|
@@ -7,6 +7,10 @@ var CacheableLookup = require('cacheable-lookup');
|
|
|
7
7
|
const cacheable = new CacheableLookup();
|
|
8
8
|
cacheable.install(https.globalAgent);
|
|
9
9
|
|
|
10
|
+
const RETRY_MAX_ATTEMPTS = 3;
|
|
11
|
+
const RETRY_BASE_DELAY_MS = 2000;
|
|
12
|
+
const RETRY_MAX_DELAY_MS = 30000;
|
|
13
|
+
|
|
10
14
|
const httpProxy = process.env.http_proxy || process.env.HTTP_PROXY || process.env.https_proxy || process.env.HTTPS_PROXY;
|
|
11
15
|
const isRetriableError = (err) => {
|
|
12
16
|
return err && err.message && (
|
|
@@ -39,7 +43,7 @@ async function request (url, options) {
|
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
options.headers = options.headers || {};
|
|
42
|
-
options.headers['User-Agent'] = `locize-cli/v12.
|
|
46
|
+
options.headers['User-Agent'] = `locize-cli/v12.3.1 (node/${process.version}; ${process.platform} ${process.arch})`; // This string is replaced with the actual version at build time by rollup
|
|
43
47
|
options.headers['X-User-Agent'] = options.headers['User-Agent'];
|
|
44
48
|
if (options.body || options.method !== 'get') options.headers['Content-Type'] = 'application/json';
|
|
45
49
|
if (options.body) {
|
|
@@ -49,19 +53,24 @@ async function request (url, options) {
|
|
|
49
53
|
}
|
|
50
54
|
if (options.headers['Authorization'] === undefined) delete options.headers['Authorization'];
|
|
51
55
|
|
|
52
|
-
async function retriableFetch (
|
|
56
|
+
async function retriableFetch (retriesLeft, attempt = 0) {
|
|
53
57
|
try {
|
|
54
58
|
const response = await fetch(url, options);
|
|
55
59
|
const result = await handleResponse(response);
|
|
56
60
|
return result
|
|
57
61
|
} catch (err) {
|
|
58
|
-
if (
|
|
62
|
+
if (retriesLeft < 1) throw err
|
|
59
63
|
if (!isRetriableError(err)) throw err
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
// Exponential backoff with equal jitter: spreads retries so a burst of
|
|
65
|
+
// concurrent CLI runs (e.g. CI) doesn't all retry in lockstep and
|
|
66
|
+
// hammer the API at the same instant.
|
|
67
|
+
const expDelay = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * (2 ** attempt));
|
|
68
|
+
const delay = Math.round(expDelay / 2 + Math.random() * (expDelay / 2));
|
|
69
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
70
|
+
return retriableFetch(retriesLeft - 1, attempt + 1)
|
|
62
71
|
}
|
|
63
72
|
}
|
|
64
|
-
return retriableFetch(
|
|
73
|
+
return retriableFetch(RETRY_MAX_ATTEMPTS)
|
|
65
74
|
}
|
|
66
75
|
|
|
67
76
|
module.exports = request;
|
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
|
-
|
|
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.
|
|
51
|
+
.version('12.3.1'); // 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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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);
|
package/dist/esm/migrate.js
CHANGED
|
@@ -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
|
-
|
|
226
|
-
|
|
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) => {
|
package/dist/esm/request.js
CHANGED
|
@@ -5,6 +5,10 @@ import CacheableLookup from 'cacheable-lookup';
|
|
|
5
5
|
const cacheable = new CacheableLookup();
|
|
6
6
|
cacheable.install(https.globalAgent);
|
|
7
7
|
|
|
8
|
+
const RETRY_MAX_ATTEMPTS = 3;
|
|
9
|
+
const RETRY_BASE_DELAY_MS = 2000;
|
|
10
|
+
const RETRY_MAX_DELAY_MS = 30000;
|
|
11
|
+
|
|
8
12
|
const httpProxy = process.env.http_proxy || process.env.HTTP_PROXY || process.env.https_proxy || process.env.HTTPS_PROXY;
|
|
9
13
|
const isRetriableError = (err) => {
|
|
10
14
|
return err && err.message && (
|
|
@@ -37,7 +41,7 @@ async function request (url, options) {
|
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
options.headers = options.headers || {};
|
|
40
|
-
options.headers['User-Agent'] = `locize-cli/v12.
|
|
44
|
+
options.headers['User-Agent'] = `locize-cli/v12.3.1 (node/${process.version}; ${process.platform} ${process.arch})`; // This string is replaced with the actual version at build time by rollup
|
|
41
45
|
options.headers['X-User-Agent'] = options.headers['User-Agent'];
|
|
42
46
|
if (options.body || options.method !== 'get') options.headers['Content-Type'] = 'application/json';
|
|
43
47
|
if (options.body) {
|
|
@@ -47,19 +51,24 @@ async function request (url, options) {
|
|
|
47
51
|
}
|
|
48
52
|
if (options.headers['Authorization'] === undefined) delete options.headers['Authorization'];
|
|
49
53
|
|
|
50
|
-
async function retriableFetch (
|
|
54
|
+
async function retriableFetch (retriesLeft, attempt = 0) {
|
|
51
55
|
try {
|
|
52
56
|
const response = await fetch(url, options);
|
|
53
57
|
const result = await handleResponse(response);
|
|
54
58
|
return result
|
|
55
59
|
} catch (err) {
|
|
56
|
-
if (
|
|
60
|
+
if (retriesLeft < 1) throw err
|
|
57
61
|
if (!isRetriableError(err)) throw err
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
// Exponential backoff with equal jitter: spreads retries so a burst of
|
|
63
|
+
// concurrent CLI runs (e.g. CI) doesn't all retry in lockstep and
|
|
64
|
+
// hammer the API at the same instant.
|
|
65
|
+
const expDelay = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * (2 ** attempt));
|
|
66
|
+
const delay = Math.round(expDelay / 2 + Math.random() * (expDelay / 2));
|
|
67
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
68
|
+
return retriableFetch(retriesLeft - 1, attempt + 1)
|
|
60
69
|
}
|
|
61
70
|
}
|
|
62
|
-
return retriableFetch(
|
|
71
|
+
return retriableFetch(RETRY_MAX_ATTEMPTS)
|
|
63
72
|
}
|
|
64
73
|
|
|
65
74
|
export { request as default };
|
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
|
-
|
|
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;
|