@tolgee/cli 2.13.0 → 2.15.0-rc.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.
@@ -7,14 +7,31 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
7
7
  step((generator = generator.apply(thisArg, _arguments || [])).next());
8
8
  });
9
9
  };
10
+ var __rest = (this && this.__rest) || function (s, e) {
11
+ var t = {};
12
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
13
+ t[p] = s[p];
14
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
15
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
16
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
17
+ t[p[i]] = s[p[i]];
18
+ }
19
+ return t;
20
+ };
10
21
  export const createExportClient = ({ apiClient }) => {
11
22
  return {
12
23
  export(req) {
13
24
  return __awaiter(this, void 0, void 0, function* () {
14
- const body = Object.assign(Object.assign({}, req), { zip: true });
25
+ const { ifNoneMatch } = req, exportReq = __rest(req, ["ifNoneMatch"]);
26
+ const body = Object.assign(Object.assign({}, exportReq), { zip: true });
27
+ const headers = {};
28
+ if (ifNoneMatch) {
29
+ headers['If-None-Match'] = ifNoneMatch;
30
+ }
15
31
  const loadable = yield apiClient.POST('/v2/projects/{projectId}/export', {
16
32
  params: { path: { projectId: apiClient.getProjectId() } },
17
33
  body: body,
34
+ headers,
18
35
  parseAs: 'blob',
19
36
  });
20
37
  return Object.assign(Object.assign({}, loadable), { data: loadable.data });
@@ -1,4 +1,3 @@
1
- import { exitWithError } from './../utils/logger.js';
2
1
  import { createApiClient } from './ApiClient.js';
3
2
  import { createExportClient } from './ExportClient.js';
4
3
  import { createImportClient } from './ImportClient.js';
@@ -9,6 +8,12 @@ export function createTolgeeClient(props) {
9
8
  }
10
9
  export const handleLoadableError = (loadable) => {
11
10
  if (loadable.error) {
12
- exitWithError(errorFromLoadable(loadable));
11
+ throw new LoadableError(loadable);
13
12
  }
14
13
  };
14
+ export class LoadableError extends Error {
15
+ constructor(loadable) {
16
+ super(errorFromLoadable(loadable));
17
+ this.loadable = loadable;
18
+ }
19
+ }
@@ -0,0 +1,120 @@
1
+ import { Stomp } from '@stomp/stompjs';
2
+ import SockJS from 'sockjs-client';
3
+ import { debug, error } from '../utils/logger.js';
4
+ export const WebsocketClient = (options) => {
5
+ let client;
6
+ let deactivated = false;
7
+ let connected = false;
8
+ let connecting = false;
9
+ let subscriptions = [];
10
+ const resubscribe = () => {
11
+ if (deactivated) {
12
+ return;
13
+ }
14
+ if (client) {
15
+ subscriptions.forEach((subscription) => {
16
+ subscribeToStompChannel(subscription);
17
+ });
18
+ }
19
+ };
20
+ const subscribeToStompChannel = (subscription) => {
21
+ if (connected && client) {
22
+ debug(`Subscribing to ${subscription.channel}`);
23
+ const stompSubscription = client.subscribe(subscription.channel, function (message) {
24
+ try {
25
+ const parsed = JSON.parse(message.body);
26
+ subscription.callback(parsed);
27
+ }
28
+ catch (e) {
29
+ error(`Error parsing message: ${e.message}`);
30
+ }
31
+ });
32
+ subscription.unsubscribe = stompSubscription.unsubscribe;
33
+ subscription.id = stompSubscription.id;
34
+ }
35
+ };
36
+ function initClient() {
37
+ client = Stomp.over(() => new SockJS(`${options.serverUrl}/websocket`));
38
+ client.configure({
39
+ reconnectDelay: 3000,
40
+ debug: (msg) => {
41
+ debug(msg);
42
+ },
43
+ });
44
+ }
45
+ function connectIfNotAlready() {
46
+ if (deactivated || connected || connecting) {
47
+ return;
48
+ }
49
+ connecting = true;
50
+ const client = getClient();
51
+ const onConnected = function (message) {
52
+ var _a;
53
+ connected = true;
54
+ connecting = false;
55
+ resubscribe();
56
+ (_a = options.onConnected) === null || _a === void 0 ? void 0 : _a.call(options, message);
57
+ };
58
+ const onDisconnect = function () {
59
+ var _a;
60
+ connected = false;
61
+ connecting = false;
62
+ (_a = options.onConnectionClose) === null || _a === void 0 ? void 0 : _a.call(options);
63
+ };
64
+ const onError = (error) => {
65
+ var _a;
66
+ connecting = false;
67
+ (_a = options.onError) === null || _a === void 0 ? void 0 : _a.call(options, error);
68
+ };
69
+ client.connect(getAuthentication(options), onConnected, onError, onDisconnect);
70
+ }
71
+ const getClient = () => {
72
+ if (client !== undefined) {
73
+ return client;
74
+ }
75
+ initClient();
76
+ return client;
77
+ };
78
+ /**
79
+ * Subscribes to channel
80
+ * @param channel Channel URI
81
+ * @param callback Callback function to be executed when event is triggered
82
+ * @return Function Function unsubscribing the event listening
83
+ */
84
+ function subscribe(channel, callback) {
85
+ if (deactivated) {
86
+ return () => { };
87
+ }
88
+ connectIfNotAlready();
89
+ const subscription = { channel, callback };
90
+ subscriptions.push(subscription);
91
+ subscribeToStompChannel(subscription);
92
+ return () => {
93
+ var _a;
94
+ (_a = subscription.unsubscribe) === null || _a === void 0 ? void 0 : _a.call(subscription);
95
+ removeSubscription(subscription);
96
+ };
97
+ }
98
+ function disconnect() {
99
+ if (client) {
100
+ client.disconnect();
101
+ }
102
+ }
103
+ function deactivate() {
104
+ deactivated = true;
105
+ disconnect();
106
+ }
107
+ function removeSubscription(subscription) {
108
+ subscriptions = subscriptions.filter((it) => it !== subscription);
109
+ }
110
+ return Object.freeze({ subscribe, deactivate, connectIfNotAlready });
111
+ };
112
+ function getAuthentication(options) {
113
+ if (options.authentication.jwtToken) {
114
+ return { jwtToken: options.authentication.jwtToken };
115
+ }
116
+ if (options.authentication.apiKey) {
117
+ return { 'x-api-key': options.authentication.apiKey };
118
+ }
119
+ return {};
120
+ }
@@ -1,5 +1,7 @@
1
+ import { getUnresolvedConflictsMessage } from '../utils/printFailedKeys.js';
1
2
  export const addErrorDetails = (loadable, showBeError = true) => {
2
- var _a, _b, _c;
3
+ var _a, _b, _c, _d, _e;
4
+ let additionalInfo = '';
3
5
  const items = [];
4
6
  items.push(`status: ${loadable.response.status}`);
5
7
  if (showBeError && ((_a = loadable.error) === null || _a === void 0 ? void 0 : _a.code)) {
@@ -8,7 +10,11 @@ export const addErrorDetails = (loadable, showBeError = true) => {
8
10
  if (loadable.response.status === 403 && ((_c = (_b = loadable.error) === null || _b === void 0 ? void 0 : _b.params) === null || _c === void 0 ? void 0 : _c[0])) {
9
11
  items.push(`missing scope: ${loadable.error.params[0]}`);
10
12
  }
11
- return `[${items.join(', ')}]`;
13
+ if (((_d = loadable.error) === null || _d === void 0 ? void 0 : _d.code) === 'conflict_is_not_resolved' &&
14
+ typeof ((_e = loadable.error.params) === null || _e === void 0 ? void 0 : _e[0]) === 'object') {
15
+ additionalInfo += getUnresolvedConflictsMessage(loadable.error.params, true);
16
+ }
17
+ return `[${items.join(', ')}]${additionalInfo ? '\n' + additionalInfo : ''}`;
12
18
  };
13
19
  export const errorFromLoadable = (loadable) => {
14
20
  switch (loadable.response.status) {
@@ -9,10 +9,11 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  };
10
10
  import { Command } from 'commander';
11
11
  import ansi from 'ansi-colors';
12
- import { saveApiKey, removeApiKeys, clearAuthStore, } from '../config/credentials.js';
13
- import { exitWithError, success } from '../utils/logger.js';
12
+ import { clearAuthStore, removeApiKeys, saveApiKey, } from '../config/credentials.js';
13
+ import { debug, exitWithError, success } from '../utils/logger.js';
14
14
  import { createTolgeeClient } from '../client/TolgeeClient.js';
15
15
  import { printApiKeyLists } from '../utils/apiKeyList.js';
16
+ import { getStackTrace } from '../utils/getStackTrace.js';
16
17
  function loginHandler(key) {
17
18
  return __awaiter(this, void 0, void 0, function* () {
18
19
  const opts = this.optsWithGlobals();
@@ -23,6 +24,7 @@ function loginHandler(key) {
23
24
  else if (!key) {
24
25
  exitWithError('Missing argument [API Key]');
25
26
  }
27
+ debug(`Logging in with API key ${key === null || key === void 0 ? void 0 : key.slice(0, 5)}...${key === null || key === void 0 ? void 0 : key.slice(-4)}.\n${getStackTrace()}`);
26
28
  const keyInfo = yield createTolgeeClient({
27
29
  baseUrl: opts.apiUrl.toString(),
28
30
  apiKey: key,
@@ -10,44 +10,12 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  import { Command, Option } from 'commander';
11
11
  import { unzipBuffer } from '../utils/zip.js';
12
12
  import { prepareDir } from '../utils/prepareDir.js';
13
- import { exitWithError, loading, success } from '../utils/logger.js';
13
+ import { exitWithError, info, loading, success } from '../utils/logger.js';
14
14
  import { checkPathNotAFile } from '../utils/checkPathNotAFile.js';
15
15
  import { mapExportFormat } from '../utils/mapExportFormat.js';
16
16
  import { handleLoadableError } from '../client/TolgeeClient.js';
17
- function fetchZipBlob(opts) {
18
- return __awaiter(this, void 0, void 0, function* () {
19
- var _a;
20
- const exportFormat = mapExportFormat(opts.format);
21
- const { format, messageFormat } = exportFormat;
22
- const loadable = yield opts.client.export.export({
23
- format,
24
- messageFormat,
25
- supportArrays: opts.supportArrays,
26
- languages: opts.languages,
27
- filterState: opts.states,
28
- structureDelimiter: (_a = opts.delimiter) !== null && _a !== void 0 ? _a : '',
29
- filterNamespace: opts.namespaces,
30
- filterTagIn: opts.tags,
31
- filterTagNotIn: opts.excludeTags,
32
- fileStructureTemplate: opts.fileStructureTemplate,
33
- });
34
- handleLoadableError(loadable);
35
- return loadable.data;
36
- });
37
- }
38
- const pullHandler = () => function () {
39
- return __awaiter(this, void 0, void 0, function* () {
40
- const opts = this.optsWithGlobals();
41
- if (!opts.path) {
42
- exitWithError('Missing option --path <path> or `pull.path` in tolgee config');
43
- }
44
- yield checkPathNotAFile(opts.path);
45
- const zipBlob = yield loading('Fetching strings from Tolgee...', fetchZipBlob(opts));
46
- yield prepareDir(opts.path, opts.emptyDir);
47
- yield loading('Extracting strings...', unzipBuffer(zipBlob, opts.path));
48
- success('Done!');
49
- });
50
- };
17
+ import { startWatching } from '../utils/pullWatch/watchHandler.js';
18
+ import { getETag } from '../utils/eTagStorage.js';
51
19
  export default (config) => {
52
20
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
53
21
  return new Command()
@@ -66,5 +34,80 @@ export default (config) => {
66
34
  .addOption(new Option('--support-arrays', 'Export keys with array syntax (e.g. item[0]) as arrays.').default((_j = (_h = config.pull) === null || _h === void 0 ? void 0 : _h.supportArrays) !== null && _j !== void 0 ? _j : false))
67
35
  .addOption(new Option('--empty-dir', 'Empty target directory before inserting pulled files.').default((_k = config.pull) === null || _k === void 0 ? void 0 : _k.emptyDir))
68
36
  .addOption(new Option('--file-structure-template <template>', 'Defines exported file structure: https://tolgee.io/tolgee-cli/push-pull-strings#file-structure-template-format').default((_l = config.pull) === null || _l === void 0 ? void 0 : _l.fileStructureTemplate))
37
+ .addOption(new Option('--watch', 'Watch for changes and re-pull automatically').default(false))
69
38
  .action(pullHandler());
70
39
  };
40
+ const pullHandler = () => function () {
41
+ return __awaiter(this, void 0, void 0, function* () {
42
+ const opts = this.optsWithGlobals();
43
+ if (!opts.path) {
44
+ exitWithError('Missing option --path <path> or `pull.path` in tolgee config');
45
+ }
46
+ yield checkPathNotAFile(opts.path);
47
+ if (!opts.watch) {
48
+ yield doPull(opts);
49
+ success('Done!');
50
+ return;
51
+ }
52
+ // Start watching for changes
53
+ yield startWatching({
54
+ apiUrl: opts.apiUrl,
55
+ apiKey: opts.apiKey,
56
+ projectId: opts.projectId,
57
+ client: opts.client,
58
+ doPull: () => __awaiter(this, void 0, void 0, function* () {
59
+ yield doPull(opts);
60
+ }),
61
+ });
62
+ });
63
+ };
64
+ const doPull = (opts) => __awaiter(void 0, void 0, void 0, function* () {
65
+ const result = yield loading('Fetching strings from Tolgee...', fetchZipBlob(opts, getETag(opts.projectId)));
66
+ if (result.notModified) {
67
+ info('Exported data not changed.');
68
+ return;
69
+ }
70
+ yield prepareDir(opts.path, opts.emptyDir);
71
+ yield loading('Extracting strings...', unzipBuffer(result.data, opts.path));
72
+ // Store ETag after a successful pull
73
+ if (result.etag) {
74
+ const { setETag } = yield import('../utils/eTagStorage.js');
75
+ setETag(opts.projectId, result.etag);
76
+ }
77
+ });
78
+ function fetchZipBlob(opts, ifNoneMatch) {
79
+ return __awaiter(this, void 0, void 0, function* () {
80
+ var _a, _b, _c;
81
+ const exportFormat = mapExportFormat(opts.format);
82
+ const { format, messageFormat } = exportFormat;
83
+ const loadable = yield opts.client.export.export({
84
+ format,
85
+ messageFormat,
86
+ supportArrays: opts.supportArrays,
87
+ languages: opts.languages,
88
+ filterState: opts.states,
89
+ structureDelimiter: (_a = opts.delimiter) !== null && _a !== void 0 ? _a : '',
90
+ filterNamespace: opts.namespaces,
91
+ filterTagIn: opts.tags,
92
+ filterTagNotIn: opts.excludeTags,
93
+ fileStructureTemplate: opts.fileStructureTemplate,
94
+ escapeHtml: false,
95
+ ifNoneMatch,
96
+ });
97
+ handleLoadableError(loadable);
98
+ const etag = loadable.response
99
+ ? extractETagFromResponse(loadable.response)
100
+ : undefined;
101
+ return {
102
+ data: loadable.data,
103
+ etag,
104
+ // 412 is not modified for POST request
105
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/412
106
+ // 304 is not modified for GET request
107
+ notModified: [412, 304].includes((_c = (_b = loadable.response) === null || _b === void 0 ? void 0 : _b.status) !== null && _c !== void 0 ? _c : 0),
108
+ };
109
+ });
110
+ }
111
+ function extractETagFromResponse(response) {
112
+ return response.headers.get('ETag') || undefined;
113
+ }
@@ -18,6 +18,7 @@ import { mapImportFormat } from '../utils/mapImportFormat.js';
18
18
  import { handleLoadableError } from '../client/TolgeeClient.js';
19
19
  import { findFilesByTemplate } from '../utils/filesTemplate.js';
20
20
  import { valueToArray } from '../utils/valueToArray.js';
21
+ import { printUnresolvedConflicts } from '../utils/printFailedKeys.js';
21
22
  function allInPattern(pattern) {
22
23
  return __awaiter(this, void 0, void 0, function* () {
23
24
  const files = [];
@@ -100,7 +101,7 @@ function handleMappingError(fileMappings) {
100
101
  }
101
102
  const pushHandler = (config) => function () {
102
103
  return __awaiter(this, void 0, void 0, function* () {
103
- var _a, _b, _c, _d;
104
+ var _a, _b, _c, _d, _e, _f, _g;
104
105
  const opts = this.optsWithGlobals();
105
106
  let allMatchers = [];
106
107
  const filesTemplate = opts.filesTemplate;
@@ -130,12 +131,25 @@ const pushHandler = (config) => function () {
130
131
  error('Nothing to import.');
131
132
  return;
132
133
  }
134
+ let errorOnUnresolvedConflict;
135
+ switch (opts.errorOnUnresolvedConflict) {
136
+ case 'auto':
137
+ errorOnUnresolvedConflict = undefined;
138
+ break;
139
+ case 'yes':
140
+ errorOnUnresolvedConflict = true;
141
+ break;
142
+ case 'no':
143
+ errorOnUnresolvedConflict = false;
144
+ break;
145
+ }
133
146
  const params = {
134
147
  createNewKeys: true,
135
148
  forceMode: opts.forceMode,
136
149
  overrideKeyDescriptions: opts.overrideKeyDescriptions,
137
150
  convertPlaceholdersToIcu: opts.convertPlaceholdersToIcu,
138
151
  tagNewKeys: (_d = opts.tagNewKeys) !== null && _d !== void 0 ? _d : [],
152
+ overrideMode: (_e = opts.overrideMode) !== null && _e !== void 0 ? _e : 'RECOMMENDED',
139
153
  fileMappings: files.map((f) => {
140
154
  var _a;
141
155
  const format = mapImportFormat(opts.format, extname(f.name));
@@ -153,30 +167,34 @@ const pushHandler = (config) => function () {
153
167
  };
154
168
  }),
155
169
  removeOtherKeys: opts.removeOtherKeys,
170
+ errorOnUnresolvedConflict: errorOnUnresolvedConflict,
156
171
  };
157
- const attempt1 = yield loading('Importing...', importData(opts.client, {
172
+ let attempt = yield loading('Importing...', importData(opts.client, {
158
173
  files,
159
174
  params,
160
175
  }));
161
- if (attempt1.error) {
162
- if (attempt1.error.code === 'existing_language_not_selected') {
176
+ if (attempt.error) {
177
+ if (attempt.error.code === 'existing_language_not_selected') {
163
178
  handleMappingError(params.fileMappings);
164
179
  }
165
- if (attempt1.error.code !== 'conflict_is_not_resolved') {
166
- handleLoadableError(attempt1);
180
+ if (attempt.error.code !== 'conflict_is_not_resolved') {
181
+ handleLoadableError(attempt);
167
182
  }
168
183
  const forceMode = yield promptConflicts(opts);
169
- const attempt2 = yield loading('Overriding...', importData(opts.client, {
184
+ attempt = yield loading('Overriding...', importData(opts.client, {
170
185
  files,
171
186
  params: Object.assign(Object.assign({}, params), { forceMode }),
172
187
  }));
173
- handleLoadableError(attempt2);
188
+ handleLoadableError(attempt);
189
+ }
190
+ if ((_g = (_f = attempt.data) === null || _f === void 0 ? void 0 : _f.unresolvedConflicts) === null || _g === void 0 ? void 0 : _g.length) {
191
+ printUnresolvedConflicts(attempt.data.unresolvedConflicts, false);
174
192
  }
175
193
  success('Done!');
176
194
  });
177
195
  };
178
196
  export default (config) => {
179
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
197
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
180
198
  return new Command()
181
199
  .name('push')
182
200
  .description('Pushes translations to Tolgee')
@@ -191,5 +209,11 @@ export default (config) => {
191
209
  .addOption(new Option('-n, --namespaces <namespaces...>', 'Specifies which namespaces should be pushed (see push.files in config).').default((_h = config.push) === null || _h === void 0 ? void 0 : _h.namespaces))
192
210
  .addOption(new Option('--tag-new-keys <tags...>', 'Specify tags that will be added to newly created keys.').default((_j = config.push) === null || _j === void 0 ? void 0 : _j.tagNewKeys))
193
211
  .addOption(new Option('--remove-other-keys', 'Remove keys which are not present in the import (within imported namespaces).').default((_k = config.push) === null || _k === void 0 ? void 0 : _k.removeOtherKeys))
212
+ .addOption(new Option('--override-mode <mode>', 'Specifies what is considered non-overridable translation.')
213
+ .choices(['RECOMMENDED', 'ALL'])
214
+ .default((_l = config.push) === null || _l === void 0 ? void 0 : _l.overrideMode))
215
+ .addOption(new Option('--error-on-unresolved-conflict <choice>', 'Fail the whole import if there are unresolved conflicts.')
216
+ .choices(['yes', 'no', 'auto'])
217
+ .default((_o = (_m = config.push) === null || _m === void 0 ? void 0 : _m.errorOnUnresolvedConflict) !== null && _o !== void 0 ? _o : 'auto'))
194
218
  .action(pushHandler(config));
195
219
  };
@@ -24,6 +24,7 @@ function backup(client, dest) {
24
24
  supportArrays: false,
25
25
  filterState: ['UNTRANSLATED', 'TRANSLATED', 'REVIEWED'],
26
26
  structureDelimiter: '',
27
+ escapeHtml: false,
27
28
  });
28
29
  handleLoadableError(loadable);
29
30
  const blob = loadable.data;
@@ -5,15 +5,20 @@ import ansi from 'ansi-colors';
5
5
  * @param key The key to print.
6
6
  * @param deletion True if the key is about to be deleted.
7
7
  */
8
- export function printKey(key, deletion) {
8
+ export function printKey(key, deletion, color, note) {
9
+ const colorFunc = color !== null && color !== void 0 ? color : (deletion ? ansi.red : ansi.green);
9
10
  const namespace = key.namespace
10
11
  ? ` ${ansi.italic(`(namespace: ${key.namespace})`)}`
11
12
  : '';
12
- if (deletion) {
13
- console.log(`${ansi.red(`- ${key.keyName}`)}${namespace}`);
13
+ const renderedNote = note ? ` ${note}` : '';
14
+ if (deletion === undefined) {
15
+ console.log(`${colorFunc(`${key.keyName}`)}${namespace}${renderedNote}`);
16
+ }
17
+ else if (deletion) {
18
+ console.log(`${colorFunc(`- ${key.keyName}`)}${namespace}${renderedNote}`);
14
19
  }
15
20
  else {
16
- console.log(`${ansi.green(`+ ${key.keyName}`)}${namespace}`);
21
+ console.log(`${colorFunc(`+ ${key.keyName}`)}${namespace}${renderedNote}`);
17
22
  }
18
23
  }
19
24
  /**
@@ -79,19 +79,20 @@ export function savePak(instance, project, pak) {
79
79
  return storePak(store, instance, project, pak);
80
80
  });
81
81
  }
82
- export function getApiKey(instance, projectId) {
82
+ export function getApiKey(apiUrl, projectId) {
83
83
  return __awaiter(this, void 0, void 0, function* () {
84
84
  var _a;
85
85
  const store = yield loadStore();
86
- if (!store[instance.hostname]) {
86
+ const apiUrlObj = new URL(apiUrl);
87
+ if (!store[apiUrlObj.hostname]) {
87
88
  return null;
88
89
  }
89
- const scopedStore = store[instance.hostname];
90
+ const scopedStore = store[apiUrlObj.hostname];
90
91
  if (scopedStore.user) {
91
92
  if (scopedStore.user.expires !== 0 &&
92
93
  Date.now() > scopedStore.user.expires) {
93
- warn(`Your personal access token for ${instance.hostname} expired.`);
94
- yield storePat(store, instance, undefined);
94
+ warn(`Your personal access token for ${apiUrlObj.hostname} expired.`);
95
+ yield storePat(store, apiUrlObj, undefined);
95
96
  return null;
96
97
  }
97
98
  return scopedStore.user.token;
@@ -102,8 +103,8 @@ export function getApiKey(instance, projectId) {
102
103
  const pak = (_a = scopedStore.projects) === null || _a === void 0 ? void 0 : _a[projectId.toString(10)];
103
104
  if (pak) {
104
105
  if (pak.expires !== 0 && Date.now() > pak.expires) {
105
- warn(`Your project API key for project #${projectId} on ${instance.hostname} expired.`);
106
- yield removePak(store, instance, projectId);
106
+ warn(`Your project API key for project #${projectId} on ${apiUrlObj.hostname} expired.`);
107
+ yield removePak(store, apiUrlObj, projectId);
107
108
  return null;
108
109
  }
109
110
  return pak.token;
@@ -0,0 +1,15 @@
1
+ // In-memory storage for ETag data
2
+ // In the future we can use filesystem to store it
3
+ const etagStorage = new Map();
4
+ export function getETag(projectId) {
5
+ const data = etagStorage.get(projectId);
6
+ if (!data) {
7
+ return undefined;
8
+ }
9
+ return data.etag;
10
+ }
11
+ export function setETag(projectId, etag) {
12
+ etagStorage.set(projectId, {
13
+ etag,
14
+ });
15
+ }
@@ -1,7 +1,9 @@
1
1
  export const getStackTrace = () => {
2
- const obj = {};
3
- Error.captureStackTrace(obj, getStackTrace);
4
- const stack = obj.stack;
5
- const parts = stack.split('\n');
6
- return parts.slice(2).join('\n');
2
+ var _a, _b;
3
+ if (typeof Error.captureStackTrace === 'function') {
4
+ const obj = {};
5
+ Error.captureStackTrace(obj, getStackTrace);
6
+ return (_a = obj.stack) !== null && _a !== void 0 ? _a : '';
7
+ }
8
+ return (_b = new Error().stack) !== null && _b !== void 0 ? _b : '';
7
9
  };
@@ -0,0 +1,29 @@
1
+ import { debug } from './logger.js';
2
+ /**
3
+ Compares two semantic versions (e.g., "3.143.0" vs "3.142.0")
4
+ Returns true if version1 >= version2
5
+ */
6
+ export function isVersionAtLeast(required, current) {
7
+ debug(`Checking that server version ${current} is at least ${required}`);
8
+ if (current === '??') {
9
+ // local build, lets assume it's supported
10
+ return true;
11
+ }
12
+ // Strip optional 'v' prefix from both versions
13
+ const cleanRequired = required.startsWith('v') ? required.slice(1) : required;
14
+ const cleanServerVersion = current.startsWith('v')
15
+ ? current.slice(1)
16
+ : current;
17
+ const requiredParts = cleanRequired.split('.').map(Number);
18
+ const currentParts = cleanServerVersion.split('.').map(Number);
19
+ const maxLength = Math.max(requiredParts.length, currentParts.length);
20
+ for (let i = 0; i < maxLength; i++) {
21
+ const requiredPart = requiredParts[i] || 0;
22
+ const currentPart = currentParts[i] || 0;
23
+ if (currentPart > requiredPart)
24
+ return true;
25
+ if (currentPart < requiredPart)
26
+ return false;
27
+ }
28
+ return true;
29
+ }
@@ -113,7 +113,7 @@ export function exitWithError(err) {
113
113
  export function loading(comment, promise) {
114
114
  if (!process.stdout.isTTY) {
115
115
  // Simple stdout without animations
116
- process.stdout.write(comment);
116
+ process.stdout.write(comment + `\n`);
117
117
  promise.then(() => process.stdout.write(` ✓ Success\n`), () => process.stdout.write(` ✗ Failure\n`));
118
118
  return promise;
119
119
  }
@@ -0,0 +1,26 @@
1
+ import ansi from 'ansi-colors';
2
+ export function renderKey(key, note) {
3
+ const colorFunc = ansi.yellow;
4
+ const namespace = key.namespace
5
+ ? ` ${ansi.italic(`(namespace: ${key.namespace})`)}`
6
+ : '';
7
+ const renderedNote = note ? ` ${note}` : '';
8
+ return `${colorFunc(`${key.keyName}`)}${namespace}${renderedNote}`;
9
+ }
10
+ export function getUnresolvedConflictsMessage(translations, isError) {
11
+ const someOverridable = Boolean(translations.find((c) => c.isOverridable));
12
+ const result = [''];
13
+ result.push(`🟡 Some translations cannot be updated:`);
14
+ translations.forEach((c) => {
15
+ result.push(renderKey({ keyName: c.keyName, namespace: c.keyNamespace }, `${c.language}` + (c.isOverridable ? ' (overridable)' : '')));
16
+ });
17
+ result.push('');
18
+ if (someOverridable) {
19
+ result.push('HINT: Overridable translations can be updated with the `--override-mode ALL`');
20
+ result.push('');
21
+ }
22
+ return result.join('\n');
23
+ }
24
+ export function printUnresolvedConflicts(translations, isError) {
25
+ console.log(getUnresolvedConflictsMessage(translations, isError));
26
+ }
@@ -0,0 +1,71 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ import { debug, error } from '../logger.js';
11
+ import { isVersionAtLeast } from '../isVersionAtLeast.js';
12
+ export function AuthErrorHandler(client) {
13
+ function handleAuthErrors(err, shutdown) {
14
+ return __awaiter(this, void 0, void 0, function* () {
15
+ var _a, _b;
16
+ if (((_a = err === null || err === void 0 ? void 0 : err.headers) === null || _a === void 0 ? void 0 : _a.message) == 'Unauthenticated') {
17
+ yield printUnauthenticatedError();
18
+ shutdown();
19
+ return;
20
+ }
21
+ if (((_b = err === null || err === void 0 ? void 0 : err.headers) === null || _b === void 0 ? void 0 : _b.message) == 'Forbidden') {
22
+ error("You're not authorized. Insufficient permissions?");
23
+ shutdown();
24
+ return;
25
+ }
26
+ });
27
+ }
28
+ function printUnauthenticatedError() {
29
+ return __awaiter(this, void 0, void 0, function* () {
30
+ const { isSupported, serverVersion } = yield isAppSupportedVersion(client);
31
+ if (isSupported) {
32
+ error("You're not authenticated. Invalid API key?");
33
+ return;
34
+ }
35
+ error(`Server version ${serverVersion} does not support CLI watch mode. Please update your Tolgee server to version ${REQUIRED_VERSION} or higher.`);
36
+ return;
37
+ });
38
+ }
39
+ function isAppSupportedVersion(client) {
40
+ return __awaiter(this, void 0, void 0, function* () {
41
+ const serverVersion = yield getTolgeeServerVersion(client);
42
+ if (!serverVersion) {
43
+ debug('Could not determine server version');
44
+ return {
45
+ isSupported: false,
46
+ serverVersion: serverVersion,
47
+ };
48
+ }
49
+ return {
50
+ isSupported: isVersionAtLeast(REQUIRED_VERSION, serverVersion),
51
+ serverVersion: serverVersion,
52
+ };
53
+ });
54
+ }
55
+ function getTolgeeServerVersion(client) {
56
+ return __awaiter(this, void 0, void 0, function* () {
57
+ var _a;
58
+ try {
59
+ const config = yield client.GET('/api/public/configuration');
60
+ const version = (_a = config.response) === null || _a === void 0 ? void 0 : _a.headers.get('x-tolgee-version');
61
+ return version || null;
62
+ }
63
+ catch (error) {
64
+ debug('Failed to get server version: ' + error);
65
+ return null;
66
+ }
67
+ });
68
+ }
69
+ return Object.freeze({ handleAuthErrors });
70
+ }
71
+ const REQUIRED_VERSION = '3.143.0';
@@ -0,0 +1,149 @@
1
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
+ return new (P || (P = Promise))(function (resolve, reject) {
4
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
8
+ });
9
+ };
10
+ import { WebsocketClient } from '../../client/WebsocketClient.js';
11
+ import { debug, error, info, success } from '../logger.js';
12
+ import { setETag } from '../eTagStorage.js';
13
+ import { clearInterval } from 'node:timers';
14
+ import { AuthErrorHandler } from './AuthErrorHandler.js';
15
+ // Polling interval as backup when WebSocket is not available (in seconds)
16
+ const POLLING_INTERVAL_SECONDS = 60;
17
+ // Debounce delay for schedulePull in milliseconds
18
+ const SCHEDULE_PULL_DEBOUNCE_MS = 500;
19
+ export function startWatching(options) {
20
+ return __awaiter(this, void 0, void 0, function* () {
21
+ const { apiUrl, apiKey, projectId, doPull, client } = options;
22
+ // Watch mode using WebsocketClient on translation-data-modified
23
+ info('Watching for translation changes... Press Ctrl+C to stop.');
24
+ let pulling = false;
25
+ /**
26
+ * Pending handles the situation when changes are detected while the pull is
27
+ * already in progress.
28
+ */
29
+ let pending = false;
30
+ let pendingEtag;
31
+ let debounceTimer;
32
+ let pollingTimer;
33
+ let lastExecutionTime = 0;
34
+ const executePull = (etag) => __awaiter(this, void 0, void 0, function* () {
35
+ if (pulling) {
36
+ pending = true;
37
+ pendingEtag = etag;
38
+ return;
39
+ }
40
+ pulling = true;
41
+ lastExecutionTime = Date.now();
42
+ try {
43
+ yield doPull();
44
+ // Store ETag after successful pull
45
+ if (etag) {
46
+ setETag(projectId, etag);
47
+ }
48
+ }
49
+ catch (e) {
50
+ error('Error during pull: ' + e.message);
51
+ debug(e);
52
+ }
53
+ finally {
54
+ pulling = false;
55
+ // If there was a pending pull (data changed when pulling), execute it now
56
+ if (pending) {
57
+ pending = false;
58
+ const capturedEtag = pendingEtag;
59
+ pendingEtag = undefined;
60
+ void executePull(capturedEtag);
61
+ }
62
+ }
63
+ });
64
+ const schedulePull = (etag) => __awaiter(this, void 0, void 0, function* () {
65
+ const now = Date.now();
66
+ const timeSinceLastExecution = now - lastExecutionTime;
67
+ // If last execution was more than 500ms ago, execute immediately
68
+ if (timeSinceLastExecution >= SCHEDULE_PULL_DEBOUNCE_MS) {
69
+ yield executePull(etag);
70
+ }
71
+ else {
72
+ // Otherwise, schedule the update with debounce
73
+ if (debounceTimer)
74
+ clearTimeout(debounceTimer);
75
+ debounceTimer = setTimeout(() => executePull(etag), SCHEDULE_PULL_DEBOUNCE_MS);
76
+ }
77
+ });
78
+ // Polling mechanism as backup
79
+ const startPolling = () => {
80
+ if (pollingTimer) {
81
+ clearInterval(pollingTimer);
82
+ }
83
+ const poll = () => __awaiter(this, void 0, void 0, function* () {
84
+ if (pulling)
85
+ return;
86
+ debug('Polling for changes...');
87
+ yield schedulePull();
88
+ });
89
+ // Set up periodic polling
90
+ pollingTimer = setInterval(poll, POLLING_INTERVAL_SECONDS * 1000);
91
+ };
92
+ const wsClient = WebsocketClient({
93
+ serverUrl: new URL(apiUrl).origin,
94
+ authentication: { apiKey: apiKey },
95
+ onConnected: () => {
96
+ debug('WebSocket connected and subscriptions active. Performing initial pull...');
97
+ schedulePull();
98
+ },
99
+ onError: (error) => {
100
+ AuthErrorHandler(client)
101
+ .handleAuthErrors(error, shutdown)
102
+ .catch((err) => {
103
+ debug('Error in handleAuthErrors: ' + err);
104
+ });
105
+ // Non-fatal: just inform
106
+ info('Websocket error encountered. Reconnecting...');
107
+ },
108
+ onConnectionClose: () => {
109
+ info('Websocket connection closed. Attempting to reconnect...');
110
+ },
111
+ });
112
+ const channel = `/projects/${projectId}/translation-data-modified`;
113
+ let unsubscribe;
114
+ function subscribe() {
115
+ unsubscribe = wsClient.subscribe(channel, () => {
116
+ debug('Data change detected by websocket. Pulling now... ');
117
+ schedulePull();
118
+ startPolling();
119
+ });
120
+ }
121
+ const shutdown = () => {
122
+ try {
123
+ unsubscribe === null || unsubscribe === void 0 ? void 0 : unsubscribe();
124
+ }
125
+ catch (_a) {
126
+ // Ignore errors during shutdown cleanup
127
+ }
128
+ try {
129
+ wsClient.deactivate();
130
+ }
131
+ catch (_b) {
132
+ // Ignore errors during shutdown cleanup
133
+ }
134
+ if (pollingTimer) {
135
+ clearInterval(pollingTimer);
136
+ }
137
+ if (debounceTimer) {
138
+ clearTimeout(debounceTimer);
139
+ }
140
+ success('Stopped watching. Bye!');
141
+ process.exit(0);
142
+ };
143
+ process.on('SIGINT', shutdown);
144
+ process.on('SIGTERM', shutdown);
145
+ subscribe();
146
+ // Keep process alive
147
+ yield new Promise(() => { });
148
+ });
149
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tolgee/cli",
3
- "version": "2.13.0",
3
+ "version": "2.15.0-rc.1",
4
4
  "type": "module",
5
5
  "description": "A tool to interact with the Tolgee Platform through CLI",
6
6
  "repository": {
@@ -30,6 +30,7 @@
30
30
  "author": "Jan Cizmar",
31
31
  "license": "MIT",
32
32
  "dependencies": {
33
+ "@stomp/stompjs": "^7.1.1",
33
34
  "ajv": "^8.17.1",
34
35
  "ansi-colors": "^4.1.3",
35
36
  "base32-decode": "^1.0.0",
@@ -38,21 +39,29 @@
38
39
  "json5": "^2.2.3",
39
40
  "jsonschema": "^1.5.0",
40
41
  "openapi-fetch": "0.13.1",
42
+ "sockjs-client": "^1.6.1",
41
43
  "tinyglobby": "^0.2.12",
42
44
  "unescape-js": "^1.1.4",
43
45
  "vscode-oniguruma": "^2.0.1",
44
46
  "vscode-textmate": "^9.1.0",
47
+ "ws": "^8.18.3",
45
48
  "yauzl": "^3.2.0"
46
49
  },
47
50
  "devDependencies": {
48
51
  "@eslint/js": "^9.16.0",
52
+ "semantic-release": "^25.0.2",
53
+ "@semantic-release/npm": "^13.1.3",
49
54
  "@semantic-release/changelog": "^6.0.3",
50
55
  "@semantic-release/git": "^10.0.1",
56
+ "@semantic-release/github": "^12.0.2",
57
+ "@semantic-release/commit-analyzer": "^13.0.1",
58
+ "@semantic-release/release-notes-generator": "^14.1.0",
51
59
  "@tsconfig/node18": "^18.2.4",
52
60
  "@tsconfig/recommended": "^1.0.8",
53
61
  "@types/eslint__js": "^8.42.3",
54
62
  "@types/js-yaml": "^4.0.9",
55
63
  "@types/node": "^22.10.1",
64
+ "@types/sockjs-client": "^1.5.4",
56
65
  "@types/yauzl": "^2.10.3",
57
66
  "cross-env": "^7.0.3",
58
67
  "eslint": "^9.16.0",
@@ -64,7 +73,6 @@
64
73
  "openapi-typescript": "^7.4.4",
65
74
  "premove": "^4.0.0",
66
75
  "prettier": "^3.4.1",
67
- "semantic-release": "^24.2.0",
68
76
  "tree-cli": "^0.6.7",
69
77
  "tsx": "^4.19.2",
70
78
  "typescript": "~5.6.3",
package/schema.json CHANGED
@@ -90,6 +90,12 @@
90
90
  "removeOtherKeys": {
91
91
  "description": "Remove keys which are not present in the import (within imported namespaces).",
92
92
  "type": "boolean"
93
+ },
94
+ "errorOnUnresolvedConflict": {
95
+ "$ref": "#/$defs/errorOnUnresolvedConflict"
96
+ },
97
+ "overrideMode": {
98
+ "$ref": "#/$defs/overrideMode"
93
99
  }
94
100
  }
95
101
  },
@@ -251,6 +257,16 @@
251
257
  "type": "string",
252
258
  "enum": ["OVERRIDE", "KEEP", "NO_FORCE"]
253
259
  },
260
+ "overrideMode": {
261
+ "description": "Specifies what is considered non-overridable translation: \n - RECOMMENDED - protected reviewed translations are considered as non-overridable\n - ALL - translations that user has permissions for",
262
+ "type": "string",
263
+ "enum": ["ALL", "RECOMMENDED"]
264
+ },
265
+ "errorOnUnresolvedConflict": {
266
+ "description": "Fail the whole import if there are unresolved conflicts in import: \n - yes - fail if any unresolved conflict is present\n no - don't fail and just print unresolved conflicts\n auto - fails when when forceMode=KEEP, otherwise doesn't fail",
267
+ "type": "string",
268
+ "enum": ["yes", "no", "auto"]
269
+ },
254
270
  "path": {
255
271
  "description": "File glob specifying which files to include.",
256
272
  "type": "string"